diff --git a/.agents/skills/cache-framework/SKILL.md b/.agents/skills/cache-framework/SKILL.md index 29f2f7e9..620409a4 100644 --- a/.agents/skills/cache-framework/SKILL.md +++ b/.agents/skills/cache-framework/SKILL.md @@ -1,108 +1,217 @@ --- name: "cache-framework" -description: "Wavelet 项目专用:当新增或修改基于 Cordis 插件的业务缓存、ctx.Cache() / contracts.CacheService 访问、三层读路径(RAM L1 + Redis L2 + DB L3)、多节点 Pub/Sub 失效同步时必须使用。" +description: "Wavelet 项目专用:当新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、或评估高频读是否应接入缓存时必须使用。本技能说明系统标准缓存框架、参考实现、禁止写法与分布式一致性要求。" --- -# 系统三层缓存框架与开发规范 (Cordis 插件化架构) +# 系统三层缓存框架 -本技能指导 Wavelet 在 Cordis 架构下,如何使用平台统一提供的三层缓存服务(`ctx.Cache()` 与 `contracts.CacheService`)进行高性能缓存读写与分布式失效同步。 +开始前阅读根目录 `AGENTS.md`(含 **Skill 关联索引**)。Wavelet 标准读路径为 **本地 RAM → Redis → PostgreSQL**(由快到慢),不是 DB 优先。 ---- +详细性能背景见 `docs/PERFORMANCE.md`。 -## 1. 三层读路径与标准契约 +## 关联 Skill -Wavelet 标准读路径为 **本地 RAM (L1) → Redis (L2) → Database (L3)**(由快到慢): +| 关联 | 何时一并阅读 | +| :--- | :--- | +| [database-migration](../database-migration/SKILL.md) | 缓存对象对应新表/列/索引,或 seed 变更 | +| [new-setting](../new-setting/SKILL.md) | 系统配置类缓存(`GetSystemConfigByKey`、`ListSystemConfigsByKeys`) | +| [file-upload](../file-upload/SKILL.md) | 上传元数据 `upload:meta:{id}`、ingest/remove/cleanup 失效钩子 | +| [clickhouse-batchwriter](../clickhouse-batchwriter/SKILL.md) | 分析写入走 batchwriter,**不要**用本技能模式缓存 CH flush 队列 | +| [new-api](../new-api/SKILL.md) | 在 Handler 层接入 `GetXxxCached` 或评估高频读 | +| [new-async-task](../new-async-task/SKILL.md) | Worker/定时任务变更数据后必须 `Invalidate*`(如 `system:cleanup`) | + +## 标准模式(金标准) + +参考:`internal/repository/system_config_cache.go` + `GetSystemConfigByKey` / `ListSystemConfigsByKeys`。 | 层级 | 技术 | 职责 | | :--- | :--- | :--- | -| **L1 本地** | `pkg/cache/ram` (Otter) | 进程内纳秒级极速读取,抗最高频热点流量 | -| **L2 共享** | Redis 序列化缓存 | 跨节点共享,具备 TTL 与防击穿保护 | -| **L3 权威** | 关系型数据库 (PostgreSQL / SQLite) | 唯一权威数据源 | +| L1 本地 | `pkg/cache/ram`(Otter v2) | 进程内热数据,最低延迟 | +| L2 共享 | Redis `db.GetJSON` / `SetJSON` / `HSetJSON` + `db.PrefixedKey` | 跨节点共享,带 TTL 或写穿 | +| L3 权威 | PostgreSQL via `db.DB(ctx)` | 唯一数据源 | -### 标准接口契约 (`contracts.CacheService`) +### 读路径模板 ```go -type CacheService interface { - // Get 从缓存获取并反序列化至 target,若不存在返回 ErrCacheMiss - Get(ctx context.Context, key string, target any) error +func GetThingCached(ctx context.Context, key string) (Thing, error) { + ensureThingCacheListener() // 订阅 pub/sub,仅 sync.Once - // Set 存储对象至缓存并设置 TTL - Set(ctx context.Context, key string, value any, ttl time.Duration) error - - // Delete 彻底移除缓存(清空本地 RAM、删除 Redis 并广播 Pub/Sub 通知全集群清空 RAM) - Delete(ctx context.Context, key string) error - - // GetOrSet 优先读缓存,若未命中则执行 loader 回源加载并自动回写 - GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error - - // Invalidate 是 Delete 的语义别名 - Invalidate(ctx context.Context, key string) error + if v, ok := thingRAM.GetIfPresent(key); ok { + return cloneThing(v), nil + } + if db.Redis != nil { + var v Thing + if err := db.GetJSON(ctx, redisKey(key), &v); err == nil { + thingRAM.Set(key, cloneThing(v)) + return v, nil + } + } + v, err := loadThingFromDB(ctx, key) + if err != nil { + return Thing{}, err + } + populateThingCache(ctx, v) // 回写 RAM + Redis + return v, nil } ``` ---- +### 写穿(populate) -## 2. 业务使用标准范式 - -### 2.1 高性能读穿透 (`GetOrSet`) - -业务 Service 推荐优先使用 `GetOrSet`,框架底层自动完成 L1/L2 穿透、回写及并发防击穿: +DB miss 或业务创建成功后,**必须**回写上层: ```go -func (s *OrderService) GetOrderWithCache(ctx context.Context, orderID string) (*Order, error) { - var order Order - cacheKey := "order:" + orderID - - err := s.cache.GetOrSet(ctx, cacheKey, &order, 10*time.Minute, func() (any, error) { - // Cache Miss: 执行 DB 回源查询 - var dbOrder Order - if err := s.db.WithContext(ctx).First(&dbOrder, "id = ?", orderID).Error; err != nil { - return nil, err - } - return &dbOrder, nil - }) - - if err != nil { - return nil, err - } - return &order, nil +func populateThingCache(ctx context.Context, v Thing) { + thingRAM.Set(v.Key, cloneThing(v)) + if db.Redis != nil { + _ = db.SetJSON(ctx, redisKey(v.Key), v, cacheTTL) + } } ``` -### 2.2 数据变更与失效广播 (`Invalidate` / `Delete`) +### 失效(Invalidate)— 分布式必做三步 -凡涉及数据创建、修改、软删除、状态变更的入口(**包含 HTTP Handler、后台 Worker 任务、定时清理任务**),必须调用缓存失效: +数据变更(Admin 更新、软删除、状态迁移)时: + +1. **本机 RAM** — `thingRAM.Invalidate(key)` 或 `InvalidateAll()` +2. **Redis** — `Del` / `HDel` 对应 key +3. **pub/sub 广播** — 通知**其他节点**清除 RAM(Redis 已由写节点清掉) ```go -func (s *OrderService) UpdateOrderStatus(ctx context.Context, orderID string, newStatus string) error { - // 1. 更新数据库权威数据 - if err := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", orderID).Update("status", newStatus).Error; err != nil { - return err - } - - // 2. 广播失效缓存(自动清除本机 L1、删除 Redis L2,并向集群广播 Pub/Sub 消息清空其他节点 L1) - return s.cache.Invalidate(ctx, "order:"+orderID) +func InvalidateThingCache(ctx context.Context, key string) error { + ensureThingCacheListener() + thingRAM.Invalidate(key) + if db.Redis != nil { + if err := db.Redis.Del(ctx, db.PrefixedKey(redisKey(key))).Err(); err != nil { + return err + } + publishThingRAMInvalidation(ctx, key) // 只广播 RAM 失效 + } + return nil } ``` ---- +### pub/sub 监听模板 -## 3. 核心规则与禁止写法 (Guardrails) +```go +const thingInvalidationChannel = "domain:thing_invalidation" -1. **严禁自研本地 map 缓存**: - - 严禁在插件内编写 `sync.RWMutex + map[string]Xxx` 的裸内存缓存,无法感知多节点数据变更,必然引发多机脏读。 -2. **写路径必须全覆盖失效**: - - 不仅在 API 修改时失效,后台 Worker、定时任务执行数据清理或变更时,必须同步触发 `cache.Invalidate`。 -3. **Key 命名空间规范**: - - 缓存 Key 必须带插件命名空间前缀(如 `order:meta:{id}`、`auth:session:{token}`)。 -4. **不可在业务高频读接口中绕过缓存直查 DB**。 +func startThingCacheInvalidationListener() { + if db.Redis == nil { + return + } + go func() { + pubsub := db.Redis.Subscribe(context.Background(), thingInvalidationChannel) + defer func() { _ = pubsub.Close() }() + for msg := range pubsub.Channel() { + // 解析 payload,Invalidate RAM;勿重复 Del Redis + thingRAM.Invalidate(parsedKey) + } + }() +} +``` ---- +- 使用 `sync.Once` 启动监听;**`ensureListener` 必须在 `db.Redis == nil` 时直接 return,不可消费 Once**(否则测试或 Redis 晚初始化时监听器永不启动)。 +- 测试可提供 `StopThingCacheListener` + 重置 `Once`(参考 `StopUploadMetaCacheListener`、`StopAuthSourceCacheListener`)。 +- 其他节点收到消息后**只清 RAM**,不再删 Redis。 -## 4. 质量与测试验证 +## 现有实现速查 + +| 域 | 文件 | L1 | L2 | pub/sub | +| :--- | :--- | :--- | :--- | :--- | +| 系统配置 | `repository/system_config_cache.go` | `pkg/cache/store` | ❌ 无 Redis 缓存 | `system:config_broadcast` (别名 `system:config_invalidation`) ✅ | +| CAPTCHA 运行时 | `apps/cap/runtime_settings.go` | atomic.Pointer | (借配置 Redis) | 订阅 `system:config_invalidation` ✅ | +| 上传元数据 | `apps/upload/cache/meta_cache.go` | Otter | Redis JSON | `upload:meta_invalidation` ✅ | +| 上传访问白名单 | `apps/upload/cache/access_cache.go` | 进程内 TTL | (借配置读路径) | `upload:file_access_invalidation` ✅ | +| Auth Source | `repository/auth_source_cache.go` | Otter | Redis JSON | `oauth:auth_source_invalidation` ✅ | +| OAuth 用户/Token | `apps/oauth/cache.go` | 自研 map | Redis JSON | ❌ 无 pub/sub(历史债) | +| 推送渠道 | `repository/push_channel.go` | 无 | Redis JSON | ❌ 仅 Redis Del | +| Storage 驱动 | `internal/infra/objectstore/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ | + +## 新增缓存工作流 + +1. **判定是否需要缓存**:高频读、低变更、可容忍短暂 TTL;写路径必须能统一失效。 +2. **选型 L1**:优先 `pkg/cache/ram.MustNew`;**禁止**自研 `map+mutex+TTL`,除非有充分理由并文档说明。 +3. **选型 L2**:小对象 `SetJSON`;配置类多条目用 Redis Hash(`HSetJSON`)。 +4. **定义 Redis key**:小写蛇形,带业务前缀(`upload:meta:{id}`);统一 `db.PrefixedKey`。 +5. **实现 Invalidate + pub/sub**:凡多实例部署可读的 RAM 缓存**必须**有失效广播。 +6. **挂载变更钩子**:在所有 DB 变更入口调用 Invalidate(含 Worker/定时任务,不只 HTTP Handler)。 +7. **测试**: + - RAM hit / Redis hit / DB fallback + - Invalidate 清 L1+L2 + - pub/sub 触发他机 RAM 失效(可用 miniredis Publish 模拟) + - `Reset*RAMCacheForTest` 仅清本机 RAM +8. 运行 `go test` 相关包 + `make code-check`。 + +## 变更钩子清单(上传元数据示例) + +| 入口 | 动作 | +| :--- | :--- | +| `ingest.persistUploadRecord` 创建成功 | `SetUploadMetaCache` | +| `ingest.Remove` / `RemoveOwned` | `InvalidateUploadMetaCache` | +| `task/cleanup.go` 软删除 pending 文件 | `InvalidateUploadMetaCache` | +| 直接 `repository.SoftDeleteUpload` | **禁止** — 必须走 `upload.Remove` | + +## 禁止写法 + +```go +// ❌ 自研 L1,与 pkg/cache/ram 重复 +var mu sync.RWMutex +var items = map[uint64]entry{} + +// ❌ 只清本机 RAM + Redis,无 pub/sub(多节点 RAM 脏读) +func Invalidate(ctx context.Context, id uint64) { + localDelete(id) + redis.Del(...) +} + +// ❌ DB 变更后忘记 Worker 路径 +// cleanup 任务删了 upload 行,但未 InvalidateUploadMetaCache + +// ❌ 在 Handler 里直接查 DB,绕过已有 GetXxxCached + +// ❌ Redis key 不用 PrefixedKey(多环境共 Redis 时冲突) + +// ❌ 在 init() 里启动 pub/sub 监听 — 与 bootstrap 规范冲突;用 sync.Once 懒启动 +``` + +## 特殊场景 + +### 敏感字段(ClientSecret) + +模型 `json:"-"` 时,Redis DTO 用独立 `*RedisRecord` struct 显式序列化字段(见 `auth_source_cache.go`)。 + +### 批量读配置 + +批量接口必须与单 key 一致走 Redis(`ListSystemConfigsByKeys` 在 RAM miss 后逐 key `HGetJSON`,再 DB `IN`)。 + +### 仅进程内、短 TTL、配置衍生 + +可用进程内快照 + 订阅上游 pub/sub(`access_cache.go`、`cap/runtime_settings.go`),不必强行 Redis L2。 + +### OAuth 用户/Token + +沿用 `oauth/cache.go`;新增逻辑调用 `SetCachedUser` / `SetCachedToken` 预热,变更调用 `InvalidateCachedUser` / `InvalidateCachedToken`。 + +## 验证清单 ```bash -make format +go test ./internal/repository/... ./internal/apps/upload/cache/... make code-check -go test ./plugins/... -``` \ No newline at end of file +``` + +- [ ] L1 使用 `pkg/cache/ram`(或已文档化的例外) +- [ ] 读路径:RAM → Redis → DB +- [ ] 写穿 populate 在 DB load / 创建成功后 +- [ ] Invalidate:RAM + Redis + Publish +- [ ] `ensureListener` + pub/sub 清他机 RAM +- [ ] 所有变更入口(含 Worker)已挂钩 +- [ ] 测试含 Invalidate 与 pub/sub + +## 相关文件 + +- L1 引擎:`pkg/cache/ram/cache.go` +- DB/Redis 助手:`internal/infra/persistence/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`) +- 金标准:`internal/repository/system_config_cache.go` +- 上传元数据:`internal/apps/upload/cache/meta_cache.go` +- Auth Source:`internal/repository/auth_source_cache.go` +- 性能文档:`docs/PERFORMANCE.md` \ No newline at end of file diff --git a/.agents/skills/clickhouse-batchwriter/SKILL.md b/.agents/skills/clickhouse-batchwriter/SKILL.md index 942739e0..9aa5532f 100644 --- a/.agents/skills/clickhouse-batchwriter/SKILL.md +++ b/.agents/skills/clickhouse-batchwriter/SKILL.md @@ -62,24 +62,26 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush | 域 | 表 | 写入路径 | | :--- | :--- | :--- | | 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` | +| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` → `logstore.Active` | +| 可观测时序 | `of_node_metric_snapshots` 等 | `openflare/chwriter` 分表 writer + 进程内短 TTL 去重 → `logstore.Active` | -**不要**把不同日志域并入同一 channel。新日志表先按 `logstore` skill 判定,再为本域建独立 writer。 +**不要**把 audit、access log、observability 并入同一 channel。 ## 新增 ClickHouse 写入工作流 1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。 2. **Goose DDL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。 3. **Repository**:实现 `BatchInsertX(ctx, []analyticsmodel.X) error`: - - `len(items)==0` 直接返回 - - `db.ChConn == nil` 返回明确错误 - - 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send` + - `len(items)==0` 直接返回 + - `db.ChConn == nil` 返回明确错误 + - 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send` 4. **Writer 胶水**(`internal/apps//`): - `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调 - 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill) - 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()` 5. **测试**: - - repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数 - - batchwriter:`go test ./internal/infra/persistence/batchwriter` + - repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数 + - batchwriter:`go test ./internal/infra/persistence/batchwriter` 6. 运行 `make code-check`;有 API 变更时 `make swagger`。 ## 背压与丢弃策略 @@ -87,7 +89,8 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush | 场景 | 推荐策略 | | :--- | :--- | | 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) | -| 可丢弃的高频日志 | 队列满 → `WithDropHandler` 记 warn;不阻塞请求 | +| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 | +| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 | ## 禁止写法 @@ -152,6 +155,9 @@ make code-check - 框架:`internal/infra/persistence/batchwriter/{config,writer,errs}.go` - 连接:`internal/infra/persistence/clickhouse.go` - 审计写入:`internal/apps/risk_control/logics.go` +- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go` - 日志抽象:`internal/repository/logstore` +- 节点访问日志 CH 实现:`internal/repository/analytics/node_access_log_writer.go` +- 可观测 CH 实现:`internal/repository/analytics/node_observability_writer.go` - 生命周期管理器:`internal/platform/lifecycle/lifecycle.go` - Bootstrap:`internal/platform/bootstrap/bootstrap.go` \ No newline at end of file diff --git a/.agents/skills/database-migration/SKILL.md b/.agents/skills/database-migration/SKILL.md index fdf3f6c4..52e01a7a 100644 --- a/.agents/skills/database-migration/SKILL.md +++ b/.agents/skills/database-migration/SKILL.md @@ -1,188 +1,138 @@ --- name: "database-migration" -description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、插件自包含 Goose SQL 迁移、embed.FS 注册、PG/SQLite 双方言支持或 ClickHouse 分析库 DDL 时必须使用。" +description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/infra/persistence/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/infra/persistence/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。" --- -# 数据库独立迁移与表结构开发规范 (Cordis 插件化架构) +# Wavelet 数据库升级操作指南 -本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行数据库表结构设计、Goose SQL 迁移与插件嵌入式注册的唯一指导规范。 +Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/infra/persistence/migrator.Migrate()`,SQL 文件嵌入在二进制中。 ---- +## 基本规则 -## 1. 核心架构:插件自包含迁移 (Self-Contained Migrations) - -在 Cordis 架构中,**彻底告别集中式单体大迁移目录**。 -每个插件在自身包内维护专属的 `migrations/` 目录,通过 Go 语言内置 `//go:embed` 打包为嵌入式文件系统,并在 `Apply(ctx *core.Context)` 时通过微内核扩展点 `ctx.Migrations().Register(...)` 自主注入。 - -``` -backend/plugins/domain/order/ -├── plugin.go -├── models.go -└── migrations/ - └── 00001_initial.sql ← 每个插件仅一个初始迁移文件 -``` - ---- - -## 2. 插件迁移代码集成标准 - -### 步骤 1:在插件内嵌入并注册迁移 - -```go -package order - -import ( - "embed" - "github.com/Rain-kl/Wavelet/core" -) - -//go:embed migrations/*.sql -var orderMigrations embed.FS - -func (p *Plugin) Apply(ctx *core.Context) error { - // 注册本插件的专属迁移(系统启动时由微内核统一收集并按版本执行) - ctx.Migrations().Register("order", orderMigrations) - return nil -} -``` - -### 步骤 2:编写 Goose SQL 脚本 (`migrations/00001_initial.sql`) - -每个插件只需维护一个 `00001_initial.sql`,包含其全部建表语句与种子数据。 +- SQL 迁移文件放在: + - `internal/infra/persistence/migrator/goose/postgres/` + - `internal/infra/persistence/migrator/goose/sqlite/` +- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。 +- 迁移文件使用 goose SQL 标记: ```sql -- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS w_orders ( - id VARCHAR(64) PRIMARY KEY, - user_id VARCHAR(64) NOT NULL, - amount BIGINT NOT NULL, - status VARCHAR(32) NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX IF NOT EXISTS idx_w_orders_user_id ON w_orders(user_id); - --- 种子数据 -INSERT INTO w_orders (id, user_id, amount, status) -VALUES ('init_001', 'system', 0, 'completed') -ON CONFLICT (id) DO NOTHING; --- +goose StatementEnd +... -- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS w_orders; --- +goose StatementEnd +... ``` ---- +- 不要把表结构、默认系统配置、默认模板、默认管理员初始化写回 Go 代码。 +- 编辑表结构(DDL)和插入表数据(DML/Seed)不要放在同一个 SQL 文件里,必须分成两个独立的 SQL 文件完成(例如,先通过一个文件修改表结构,再通过下一个递增版本号的文件插入/初始化数据)。 +- 插入定时任务(schedules 表数据)时绝对不能指定 `id`,必须依靠数据库自增(Identity 或 AUTOINCREMENT)自动分配,防止与用户手动或后续插入的定时任务产生 ID 冲突。 +- 不要添加物理外键;关系字段使用显式索引。 +- 数据库默认值应匹配 Go model 零值或业务兜底值。 +- 系统配置仍然保存字符串值;布尔值写 `"true"` / `"false"`,数字写十进制字符串,复杂结构写合法 JSON 字符串。 -## 3. 版本管理与升级机制 +## 新增迁移流程 -### 3.1 版本表结构 +1. 先确认涉及的 Go model、读写路径和前端/接口消费方。 +2. 选择下一个递增版本号,格式建议 `YYYYMMDDNNNN`,例如: -所有插件共享一张 `w_schema_versions` 表,以 `plugin_id` 为区分: - -```sql -w_schema_versions ( - plugin_id VARCHAR(64) NOT NULL, -- 如 "auth", "user", "admin" - version_id BIGINT NOT NULL, -- 迁移文件版本号 (00001 → 1) - applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (plugin_id, version_id) -) +```text +202606090002_add_example_column.sql ``` -### 3.2 升级判定逻辑 - -启动时,`gooseEngine` 遍历每个已注册的插件: - -``` -for each plugin: - 1. 查询 w_schema_versions WHERE plugin_id = 'auth' - 2. 获取该插件的最大 version_id - 3. 读取插件 migration/ 目录下的所有 .sql 文件 - 4. 如果存在 version_id 更大的文件 → 执行升级 - 5. 如果全部已应用 → 跳过 -``` - -### 3.3 什么情况下升级? - -| 场景 | 例子 | 是否升级 | -|------|------|---------| -| 首次部署,插件第一次运行 | auth 插件,表不存在 | ✅ 执行 `00001_initial.sql` | -| 第二次启动,无变化 | 文件未变,版本已记录 | ❌ 跳过 | -| 追加新迁移文件 | 新增 `00002_add_index.sql` | ✅ 执行 `00002_*` | -| 移除一个插件 | 该插件不再注册 | ❌ 其记录在表中被忽略 | -| 新增一个插件 | 新插件有 `00001_initial.sql` | ✅ 执行 | - -### 3.4 查看全局迁移状态 - -```sql -SELECT * FROM w_schema_versions ORDER BY plugin_id, version_id; -``` - -输出示例: - -``` -plugin_id | version_id | applied_at ----------------------+------------+--------------------------- -admin | 1 | 2026-08-28 10:00:00+00 -auth | 1 | 2026-08-28 10:00:00+00 -user | 1 | 2026-08-28 10:00:00+00 -upload | 1 | 2026-08-28 10:00:00+00 -``` - ---- - -## 4. 核心设计与防线原则 (Guardrails) - -1. **表单一所有者原则 (Single Owner Principle)**: - - 每张数据表归属且仅归属于一个所有者插件(如 `w_orders` 归 `order` 插件)。 - - **严禁**插件 B 跨包编写 SQL 直接读写插件 A 拥有的表;必须通过插件 A 暴露的 `contracts` 接口或事件总线进行交互。 - -2. **表名前缀规范**: - - 所有表名必须带有前缀(如 `w_orders`、`w_auth_users`),杜绝跨插件表名冲突。 - -3. **单文件初始迁移**: - - 每个插件只维护一个 `00001_initial.sql`,包含该插件所有表的建表语句与初始种子数据。 - - 未来如需追加 DDL,新增 `00002_xxx.sql`,Goose 会根据 `w_schema_versions` 判断增量执行。 - -4. **禁止物理外键**: - - 关系字段统一显式建立单列或联合索引,禁止在数据库中创建物理外键约束。 - -5. **双方言兼容性(PostgreSQL & SQLite)**: - - 自增主键:PG 用 `BIGSERIAL`,SQLite 用 `INTEGER PRIMARY KEY AUTOINCREMENT`。 - - 时间类型:PG 用 `TIMESTAMPTZ`,SQLite 用 `DATETIME`。 - - JSON 类型:PG 用 `JSONB`,SQLite 用 `JSON` 或 `TEXT`。 - -6. **幂等性要求**: - - 所有 `CREATE TABLE` 必须使用 `IF NOT EXISTS`。 - - 所有 `INSERT` 种子数据必须使用 `ON CONFLICT DO NOTHING`。 - - 所有 `ALTER TABLE ADD COLUMN` 必须使用 `IF NOT EXISTS`(如果数据库方言支持)。 - ---- - -## 5. ClickHouse 分析库迁移规则 (辅助 OLAP) - -ClickHouse 作为辅助 OLAP 分析存储,采用独立迁移通道: -- 迁移文件位于专属目录 `migrations-clickhouse/`(仅单方言 DDL,不创建 SQLite 镜像)。 -- 日志/分析用途表必须同时在关系型主库建回落表并接入 `logstore` 门面。 -- 分析表高频写入统一接入 `batchwriter` 进行异步批量刷盘。 - ---- - -## 6. 质量与验证门禁 +3. 在 PostgreSQL 和 SQLite 目录各新增同名 SQL 文件。 +4. 写 `Up`: + - 表结构变更使用 SQL DDL。 + - 初始化/seed 数据使用 SQL `INSERT`。 + - 需要幂等时使用 `IF NOT EXISTS` 或 `ON CONFLICT ... DO NOTHING`。 +5. 写 `Down`: + - 能安全回滚的结构变更写反向 DDL。 + - seed 数据按 key/name 等稳定标识删除。 +6. 如果变更 API handler,运行 `make swagger`。 +7. 至少运行: ```bash -make format +go test ./internal/infra/persistence/migrator +go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config make code-check -go test ./plugins/... ``` -验证迁移注册完整性: +## 方言注意事项 + +- PostgreSQL 自增主键用 `BIGSERIAL`;SQLite 自增主键用 `INTEGER PRIMARY KEY AUTOINCREMENT`。 +- PostgreSQL 时间类型优先 `TIMESTAMPTZ`;SQLite 使用 `DATETIME`。 +- PostgreSQL JSON 字段用 `JSONB`;SQLite 用 `JSON` 或 `TEXT`。 +- 两个方言目录的字段名、索引名、seed 数据语义必须保持一致。 + +## 修改默认系统配置 + +- 新增或调整系统配置 seed 时,更新两个方言的 SQL 文件。 +- `visibility` 使用常量语义:`0` 不公开,`1` 通过 `/api/v1/config/public` 返回。 +- 公共配置 API 直接返回所有 `visibility = 1` 的配置键值,不要在 handler 中重新硬编码 key 列表。 + +## 验证重点 + +- goose 能在空库上完整执行。 +- `system_configs`、默认 `admin`、内置模板能按预期初始化。 +- 新增表/列与 Go model 的列名、类型和默认值兼容。 +- 前端或接口消费的公共配置值仍按字符串解析。 + +## ClickHouse 分析库(辅助 OLAP) + +ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线: + +- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。 +- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。 + +**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。 + +### 目录与职责 + +| 路径 | 职责 | +| :--- | :--- | +| `internal/infra/persistence/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) | +| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 | +| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) | +| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`ChDB` GORM 查询) | + +### 迁移入口与版本表 + +- 入口:`migrator.MigrateClickHouse()`,在 `cmd/root.go` 的 `PreRun` 中于 `migrator.Migrate()` 之后调用。 +- 仅当 `clickhouse.enabled: true` 时执行;禁用时直接跳过(见 `TestMigrateClickHouseSkipsWhenDisabled`)。 +- 版本表:`goose_clickhouse_version`,与主库 `goose_db_version` **分离**,互不影响。 +- 方言:仅 ClickHouse,**无** SQLite 镜像目录。 + +### ClickHouse 迁移规则 + +1. **DDL 只写 goose SQL**:`CREATE TABLE IF NOT EXISTS ...`,禁止 GORM `AutoMigrate`、禁止在 repository 或 handler 中建表。 +2. **无事务**:ClickHouse 不支持 goose 事务包装;每个 `Up`/`Down` 语句独立提交。 +3. **幂等 Up**:表用 `IF NOT EXISTS`;`Down` 用 `DROP TABLE IF EXISTS`。 +4. **Down 谨慎**:MergeTree 等引擎上 `DROP TABLE` 会立即删除数据,生产环境通常只前滚;仅在开发/测试需要回滚时编写 `Down`。 +5. **DDL 与 DML 分离**:与主库相同,表结构变更与数据初始化分文件、分版本号;分析表通常无 seed,批量写入由 repository 在运行时完成。 +6. **引擎与排序键**:在 SQL 中显式声明 `ENGINE`、`PARTITION BY`、`ORDER BY` 等,与查询模式对齐(例如按 `created_at` 分区)。 +7. **禁止重复 DDL**:不要在 `support-files/`、`apps` 初始化逻辑或 `repository/analytics` 中复制建表语句。 + +### 新增分析表工作流 + +按以下顺序落地,避免列名或类型漂移: + +1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。 +2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。 +3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。 +4. **Apps**:在 `internal/apps//` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。 + +### ClickHouse 验证 + +至少运行: ```bash -# 检查每个有 migrations/ 目录的插件是否同时有 go:embed + Register() -grep -rn 'go:embed.*migrations' backend/plugins/domain/*/plugin.go backend/plugins/drivers/*/plugin.go -grep -rn 'Migrations()\.Register' backend/plugins/domain/*/plugin.go backend/plugins/drivers/*/plugin.go -``` \ No newline at end of file +go test ./internal/infra/persistence/migrator +go test ./internal/repository/analytics +make code-check +``` + +验证重点: + +- goose 能在空 ClickHouse 实例上完整执行 `Up`。 +- `internal/model/analytics` 列名、类型与 goose SQL 一致。 +- repository 读写路径不依赖 handler 内联 SQL。 +- `clickhouse.enabled: false` 时启动不报错、不执行迁移。 diff --git a/.agents/skills/file-upload/SKILL.md b/.agents/skills/file-upload/SKILL.md index b17b0dc8..dd90430b 100644 --- a/.agents/skills/file-upload/SKILL.md +++ b/.agents/skills/file-upload/SKILL.md @@ -5,7 +5,7 @@ description: "Wavelet 项目专用:当业务需要上传文件、读取已上 # 存储引擎与文件上传开发规范 -本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](../../../AGENTS.md),遵守项目级核心规则。 +本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。 --- @@ -83,8 +83,8 @@ invoice.FilePath = "uploads/2026/01/02/123.pdf" import ( "bytes" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/pkg/model" + "OpenFlare/internal/apps/upload" + "OpenFlare/internal/model" ) func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) { @@ -217,7 +217,7 @@ upload.RebuildUploadStats(ctx) // 从 w_uploads 全量重建统计 - **禁止**在源码目录硬编码 `uploads/test` 路径;本地文件测试用 `t.TempDir()` 或 mock backend - 覆盖:三种 Policy、Remove 后统计归零、ReadOnly 拒绝写入 -参考:`internal/apps/upload/ingest/ingest_test.go` +参考:[internal/apps/upload/ingest/ingest_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/upload/ingest/ingest_test.go) ### Handler 回归 diff --git a/.agents/skills/go-packages/SKILL.md b/.agents/skills/go-packages/SKILL.md new file mode 100644 index 00000000..e776f669 --- /dev/null +++ b/.agents/skills/go-packages/SKILL.md @@ -0,0 +1,138 @@ +--- +name: go-packages +description: Use when creating Go packages, organizing imports, managing dependencies, or deciding how to structure Go code into packages. Also use when starting a new Go project or splitting a growing codebase into packages, even if the user doesn't explicitly ask about package organization. Does not cover naming individual identifiers (see go-naming). +license: Apache-2.0 +metadata: + sources: "Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments" +--- + +# Go 包和 Import + +> **本技能不适用的场景**:对于包内单个标识符的命名,参见 [go-naming](../go-naming/SKILL.md)。对于单文件中函数的组织,参见 [go-functions](../go-functions/SKILL.md)。对于强制执行 import 规则的 linter 配置,参见 [go-linting](../go-linting/SKILL.md)。 + +## 包组织 + +### 避免 Util 包 + +包名应描述包提供的内容。避免使用 `util`、`helper`、`common` 等泛化名称——它们会模糊含义并导致 import 冲突。 + +```go +// 好:有意义的包名 +db := spannertest.NewDatabaseFromFile(...) +_, err := f.Seek(0, io.SeekStart) + +// 不好:模糊的名称遮蔽含义 +db := test.NewDatabaseFromFile(...) +_, err := f.Seek(0, common.SeekStart) +``` + +泛化名称可以作为名称的*一部分*(例如 `stringutil`),但不应成为整个包名。 + +### Package Size + +| 问题 | 操作 | +|------|------| +| 你能用一句话描述它的用途吗? | 不能 → 按职责拆分 | +| 文件中从未共享未导出的符号? | 这些文件可以是独立的包 | +| 不同的用户群体使用不同部分? | 按用户边界拆分 | +| Godoc 页面过于庞大? | 拆分以提高可发现性 | + +**不要拆分**的原因仅仅是文件很长、创建只有单一类型的包,或会产生循环依赖。 + +> 在决定是否拆分或合并包、组织包内文件或构建 CLI 程序时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。 + +--- + +## Import + +Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。使用 +[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) 自动管理。 + +```go +import ( + "fmt" + "os" + + "github.com/foo/bar" + "rsc.io/goversion/version" +) +``` + +**快速规则:** + +| 规则 | 指导 | +|------|------| +| 分组 | 标准库优先,然后是外部包。扩展分组:标准库 → 其他 → proto → 副作用 | +| 重命名 | 除非冲突,否则避免重命名。重命名最本地的 import。Proto 包加 `pb` 后缀 | +| 空白 import(`import _`) | 仅在 `main` 包或测试中使用 | +| 点 import(`import .`) | 永不使用,除非用于循环依赖的测试文件 | + +> 在组织扩展分组的 import、重命名 proto 包或决定使用空白/点 import 时,阅读 [references/IMPORTS.md](references/IMPORTS.md)。 + +--- + +## 避免 init() + +尽可能避免 `init()`。当不可避免时,它必须是: + +1. 完全确定性的 +2. 不依赖于其他 `init()` 的执行顺序 +3. 不依赖环境状态(环境变量、工作目录、参数) +4. 不进行 I/O(文件系统、网络、系统调用) + +**可接受的使用场景**:无法用单个赋值完成的复杂表达式、可插拔钩子(例如 `database/sql` 方言)、确定性预计算。 + +> 在需要将 init() 重构为显式函数或理解可接受的 init() 使用场景时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。 + +--- + +## Main 中的退出 + +仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应返回 error。 + +**原因**:不明显的控制流、不可测试、`defer` 语句被跳过。 + +**最佳实践**:使用 `run()` 模式——将逻辑提取到 +`func run() error` 中,在 `main()` 中调用并使用单一退出点: + +```go +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} +``` + +> 在实现 run() 模式、构建 CLI 子命令或选择 flag 命名约定时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。 + +--- + +## 命令行 Flag + +> **建议**:仅在 `package main` 中定义 flag。 + +- Flag 名称使用 `snake_case`:`--output_dir` 而非 `--outputDir` +- 库应通过参数接收配置,而非直接读取 flag—— + 这使它们可测试且可复用 +- 优先使用标准 `flag` 包;仅在需要 POSIX 约定 + (双破折号、单字符快捷方式)时使用 `pflag` + +```go +// 好:Flag 在 main 中定义,作为参数传递给库 +func main() { + outputDir := flag.String("output_dir", ".", "directory for output files") + flag.Parse() + if err := mylib.Generate(*outputDir); err != nil { + log.Fatal(err) + } +} +``` + +--- + +## 相关技能 + +- **包命名**:在选择包名、避免名称重复或命名导出符号时,参见 [go-naming](../go-naming/SKILL.md) +- **跨包的错误处理**:在使用 `%w` vs `%v` 在包边界包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md) +- **Import linting**:在配置 goimports local-prefixes 或强制执行 import 分组时,参见 [go-linting](../go-linting/SKILL.md) +- **全局状态**:在用显式初始化替换 `init()` 或避免可变全局变量时,参见 [go-defensive](../go-defensive/SKILL.md) diff --git a/.agents/skills/go-packages/references/IMPORTS.md b/.agents/skills/go-packages/references/IMPORTS.md new file mode 100644 index 00000000..eb468c65 --- /dev/null +++ b/.agents/skills/go-packages/references/IMPORTS.md @@ -0,0 +1,110 @@ +# Import 组织 + +Go import 组织的详细规则和示例。 + +## Import 分组 + +Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。 + +**最小分组(Uber):** 标准库,然后其他所有。 + +**扩展分组(Google):** 标准库 → 其他 → protocol buffers → 副作用。 + +```go +// 好:标准库与外部包分开 +import ( + "fmt" + "os" + + "go.uber.org/atomic" + "golang.org/x/sync/errgroup" +) +``` + +```go +// 好:完整分组,包含 proto 和副作用 +import ( + "fmt" + "os" + + "github.com/dsnet/compress/flate" + "golang.org/x/text/encoding" + + foopb "myproj/foo/proto/proto" + + _ "myproj/rpc/protocols/dial" +) +``` + +## Import 重命名 + +避免重命名 import,除非为了避免名称冲突;好的包名不需要重命名。 +在发生冲突时,**优先重命名最本地的或项目特定的 import**。 + +**必须重命名:** 与其他 import 冲突、生成的 protocol buffer 包 +(删除下划线,添加 `pb` 后缀)。 + +**可以重命名:** 无意义的名称(例如 `v1`)、与本地变量冲突。 + +```go +// 好:Proto 包用 pb 后缀重命名 +import ( + foosvcpb "path/to/package/foo_service_go_proto" +) + +// 好:当需要 url 变量时使用 urlpkg +import ( + urlpkg "net/url" +) + +func parseEndpoint(url string) (*urlpkg.URL, error) { + return urlpkg.Parse(url) +} +``` + +## 空白 Import(`import _`) + +仅为副作用而导入的包(使用 `import _ "pkg"`) +应仅在程序的主包(main)或需要它们的测试中导入。 + +```go +// 好:在主包中使用空白 import +package main + +import ( + _ "time/tzdata" + _ "image/jpeg" +) +``` + +## 点 Import(`import .`) + +**不要**使用点 import。它们使程序难以阅读,因为不清楚 +`Quux` 这样的名称是当前包中的顶层标识符还是导入包中的。 + +**例外:** `import .` 形式在由于循环依赖而无法成为被测试包的一部分的测试文件中可能有用: + +```go +package foo_test + +import ( + "bar/testutil" // 也导入了 "foo" + . "foo" +) +``` + +在这种情况下,测试文件不能是 `foo` 包,因为它使用了 +`bar/testutil`,而后者导入了 `foo`。因此 `import .` 形式让文件 +假装是 `foo` 包的一部分,即使实际上不是。 + +**除了这一种情况外,不要在程序中使用 `import .`。** + +```go +// 不好:点 import 隐藏了来源 +import . "foo" +var myThing = Bar() // Bar 来自哪里? + +// 好:显式限定 +import "foo" +var myThing = foo.Bar() +``` diff --git a/.agents/skills/go-packages/references/PACKAGE-SIZE.md b/.agents/skills/go-packages/references/PACKAGE-SIZE.md new file mode 100644 index 00000000..58717a0f --- /dev/null +++ b/.agents/skills/go-packages/references/PACKAGE-SIZE.md @@ -0,0 +1,214 @@ +# 包大小、程序结构和 CLI + +关于包拆分、避免 init()、run() 模式和 CLI 结构的详细指南。 + +## 何时拆分包 + +``` +包是否变得太大? +├─ 你能用一句话描述它的用途吗? +│ ├─ 不能 → 按职责拆分 +│ └─ 能 → 保留,但检查以下内容 +├─ 包中的文件是否从未导入彼此的未导出符号? +│ └─ 是 → 这些文件可以是独立的包 +├─ 包是否有不同的用户群体使用不同部分? +│ └─ 是 → 按用户边界拆分 +└─ godoc 页面是否过于庞大? + └─ 是 → 拆分以提高可发现性 +``` + +### 何时不应拆分 + +- 不要仅因为文件很长就拆分——聚焦的包中的大文件是可以的 +- 不要创建只包含一个类型或函数的包 +- 如果会产生循环依赖则不要拆分 +- 避免将内部辅助工具拆分到 `util` 或 `internal/helpers` 包中 + +### 何时合并包 + +- 如果客户端代码很可能需要两个类型交互,保持它们在一起 +- 如果类型有紧密耦合的实现 +- 如果用户需要同时导入两个包才能有意义地使用其中任何一个 + +### 文件组织 + +Go 中没有"一个类型一个文件"的惯例。文件应该足够聚焦以便知道哪个文件包含什么内容,且足够小以便轻松查找。 + +--- + +## 避免 init() + +优先使用显式函数而非 `init()`: + +```go +// 不好:init() 带有 I/O 和环境依赖 +var _config Config + +func init() { + cwd, _ := os.Getwd() + raw, _ := os.ReadFile(path.Join(cwd, "config.yaml")) + yaml.Unmarshal(raw, &_config) +} +``` + +```go +// 好:用于加载配置的显式函数 +func loadConfig() (Config, error) { + cwd, err := os.Getwd() + if err != nil { + return Config{}, err + } + + raw, err := os.ReadFile(path.Join(cwd, "config.yaml")) + if err != nil { + return Config{}, err + } + + var config Config + if err := yaml.Unmarshal(raw, &config); err != nil { + return Config{}, err + } + return config, nil +} +``` + +**init() 的可接受使用场景:** +- 无法用单个赋值完成的复杂表达式 +- 可插拔钩子(例如 `database/sql` 方言、编码注册表) +- 确定性预计算 + +--- + +## Main 中的退出 + +仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应 +返回 error 来表示失败。 + +**为什么这很重要:** +- 不明显的控制流:任何函数都可以退出程序 +- 难以测试:退出程序的函数也会退出测试 +- 跳过的清理:`defer` 语句会被跳过 + +```go +// 不好:在辅助函数中使用 log.Fatal +func readFile(path string) string { + f, err := os.Open(path) + if err != nil { + log.Fatal(err) // 退出程序,跳过 defer + } + b, err := io.ReadAll(f) + if err != nil { + log.Fatal(err) + } + return string(b) +} +``` + +```go +// 好:返回 error,让 main() 决定是否退出 +func main() { + body, err := readFile(path) + if err != nil { + log.Fatal(err) + } + fmt.Println(body) +} + +func readFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + b, err := io.ReadAll(f) + if err != nil { + return "", err + } + return string(b), nil +} +``` + +### run() 模式 + +优先在 `main()` 中**最多调用一次** `os.Exit` 或 `log.Fatal`。将 +业务逻辑提取到返回 error 的独立函数中。 + +```go +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + args := os.Args[1:] + if len(args) != 1 { + return errors.New("missing file") + } + + f, err := os.Open(args[0]) + if err != nil { + return err + } + defer f.Close() // 将始终执行 + + b, err := io.ReadAll(f) + if err != nil { + return err + } + + // 处理 b... + return nil +} +``` + +**`run()` 模式的优势:** +- 简短的 `main()` 函数,单一退出点 +- 所有业务逻辑都可测试 +- `defer` 语句始终执行 + +--- + +## 命令行接口 + +### Flag 命名 + +使用小写、连字符分隔的 flag 名称: + +```go +// 好 +flag.String("output-dir", ".", "directory for output files") +flag.Bool("dry-run", false, "print actions without executing") + +// 不好 +flag.String("outputDir", ".", "") // camelCase +flag.String("output_dir", ".", "") // 下划线 +``` + +### 子命令 + +对于带有子命令的复杂 CLI,为每个子命令使用 `flag.NewFlagSet`: + +```go +func main() { + serveCmd := flag.NewFlagSet("serve", flag.ExitOnError) + port := serveCmd.Int("port", 8080, "listen port") + + migrateCmd := flag.NewFlagSet("migrate", flag.ExitOnError) + dryRun := migrateCmd.Bool("dry-run", false, "preview changes") + + switch os.Args[1] { + case "serve": + serveCmd.Parse(os.Args[2:]) + runServe(*port) + case "migrate": + migrateCmd.Parse(os.Args[2:]) + runMigrate(*dryRun) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + os.Exit(1) + } +} +``` + +对于更大的 CLI,考虑使用 `cobra` 或 `urfave/cli` 等库。仅从 +`main()` 退出。 diff --git a/.agents/skills/go-performance/SKILL.md b/.agents/skills/go-performance/SKILL.md new file mode 100644 index 00000000..7a89f9a1 --- /dev/null +++ b/.agents/skills/go-performance/SKILL.md @@ -0,0 +1,152 @@ +--- +name: go-performance +description: Use when optimizing Go code, investigating slow performance, or writing performance-critical sections. Also use when a user mentions slow Go code, string concatenation in loops, or asks about benchmarking, even if the user doesn't explicitly mention performance patterns. Does not cover concurrent performance patterns (see go-concurrency). +license: Apache-2.0 +metadata: + sources: "Uber Style Guide, Google Style Guide, Go Wiki CodeReviewComments" +allowed-tools: Bash(bash:*) +--- + +# Go 性能模式 + +## 可用脚本 + +- **`scripts/bench-compare.sh`** — 运行 Go 基准测试 N 次,并可选通过 benchstat 进行基线比较。支持保存结果以供未来比较。运行 `bash scripts/bench-compare.sh --help` 查看选项。 + +性能特定的指南仅适用于**热点路径**。不要过早优化——将这些模式集中在最重要的地方。 + +--- + +## 优先使用 strconv 而非 fmt + +在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快: + +```go +s := strconv.Itoa(rand.Int()) // 比 fmt.Sprint() 快约 2 倍 +``` + +| 方式 | 速度 | 分配次数 | +|------|------|---------| +| `fmt.Sprint` | 143 ns/op | 2 allocs/op | +| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op | + +> 在 strconv 和 fmt 之间选择类型转换方式时,或需要完整的转换对照表时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。 + +--- + +## 避免重复的字符串到字节转换 + +将固定字符串在循环外转换为 `[]byte` 一次: + +```go +data := []byte("Hello world") +for i := 0; i < b.N; i++ { + w.Write(data) // 比每次迭代 []byte("...") 快约 7 倍 +} +``` + +> 在优化热点循环中的重复字节转换时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。 + +--- + +## 优先指定容器容量 + +尽可能指定容器容量,以便预先分配内存。这可以最大程度减少后续添加元素时因复制和调整大小而产生的分配。 + +### Map 容量提示 + +使用 `make()` 初始化 map 时提供容量提示: + +```go +m := make(map[string]os.DirEntry, len(files)) +``` + +**注意**:与 slice 不同,map 的容量提示不保证完整的预分配——它只是近似计算所需的哈希桶数量。 + +### Slice 容量 + +使用 `make()` 初始化 slice 时提供容量提示,特别是在追加时: + +```go +data := make([]int, 0, size) +``` + +与 map 不同,slice 容量**不是提示**——编译器会精确分配那么多内存。后续的 `append()` 操作在达到容量之前不会产生任何分配。 + +| 方式 | 时间(1 亿次迭代) | +|------|------------------------| +| 无容量 | 2.48s | +| 指定容量 | 0.21s | + +指定容量的版本**快约 12 倍**,因为追加期间零重新分配。 + +--- + +## 传值 + +不要仅为了节省几个字节就将指针作为函数参数传递。如果函数在整个函数体中仅通过 `*x` 引用其参数 `x`,则该参数不应该是`指针。 + +```go +func process(s string) { // 不是 *string —— string 是小的固定大小头部 + fmt.Println(s) +} +``` + +**常见的按值传递类型**:`string`、`io.Reader`、小结构体。 + +**例外**: +- 复制代价高的大结构体 +- 未来可能增长的小结构体 + +--- + +## 字符串拼接 + +根据复杂度选择正确的策略: + +| 方法 | 最佳用途 | +|------|---------| +| `+` | 少量字符串,简单拼接 | +| `fmt.Sprintf` | 混合类型的格式化输出 | +| `strings.Builder` | 循环/逐段构建 | +| `strings.Join` | 连接 slice | +| 反引号字面量 | 常量多行文本 | + +> 在选择字符串拼接策略、在循环中使用 strings.Builder 或在 fmt.Sprintf 和手动拼接之间做决定时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。 + +--- + +## 基准测试和性能分析 + +在优化前后始终要进行测量。使用 Go 内置的基准测试框架和性能分析工具。 + +```bash +go test -bench=. -benchmem -count=10 ./... +``` + +> 在编写基准测试、使用 benchstat 比较结果、使用 pprof 进行性能分析或解读基准测试输出时,阅读 [references/BENCHMARKS.md](references/BENCHMARKS.md)。 + +> **验证**:在应用优化后,运行 `bash scripts/bench-compare.sh` 测量实际影响。只保留有可衡量改进的优化。 + +--- + +## 快速参考 + +| 模式 | 不好 | 好 | 改进 | +|------|-----|------|-------------| +| 整数转字符串 | `fmt.Sprint(n)` | `strconv.Itoa(n)` | 快约 2 倍 | +| 重复 `[]byte` | 循环中 `[]byte("str")` | 在循环外转换一次 | 快约 7 倍 | +| Map 初始化 | `make(map[K]V)` | `make(map[K]V, size)` | 更少分配 | +| Slice 初始化 | `make([]T, 0)` | `make([]T, 0, cap)` | 快约 12 倍 | +| 小型固定大小参数 | `*string`、`*io.Reader` | `string`、`io.Reader` | 无间接引用 | +| 简单字符串连接 | `s1 + " " + s2` | (已经很好) | 对少量字符串使用 `+` | +| 循环构建字符串 | 重复 `+=` | `strings.Builder` | O(n) vs O(n²) | + +--- + +## 相关技能 + +- **数据结构**:在 slice、map 和数组之间选择或理解分配语义时,参见 [go-data-structures](../go-data-structures/SKILL.md) +- **声明模式**:在使用 `make` 配合容量提示或初始化 map 和 slice 时,参见 [go-declarations](../go-declarations/SKILL.md) +- **并发**:在跨 goroutine 并行化工作或使用 sync.Pool 复用缓冲区时,参见 [go-concurrency](../go-concurrency/SKILL.md) +- **风格原则**:在判断优化是否值得牺牲可读性时,参见 [go-style-core](../go-style-core/SKILL.md) diff --git a/.agents/skills/go-performance/references/BENCHMARKS.md b/.agents/skills/go-performance/references/BENCHMARKS.md new file mode 100644 index 00000000..46d19199 --- /dev/null +++ b/.agents/skills/go-performance/references/BENCHMARKS.md @@ -0,0 +1,281 @@ +# 基准测试方法 + +## 编写基准测试 + +Go 基准测试使用 `testing.B` 类型,位于 `_test.go` 文件中。 +基准测试函数名必须以 `Benchmark` 开头。 + +```go +func BenchmarkStrconv(b *testing.B) { + for i := 0; i < b.N; i++ { + s := strconv.Itoa(rand.Int()) + _ = s + } +} + +func BenchmarkFmtSprint(b *testing.B) { + for i := 0; i < b.N; i++ { + s := fmt.Sprint(rand.Int()) + _ = s + } +} +``` + +关键规则: +- 使用 `b.N` 作为循环边界——框架会调整它以获得稳定的计时 +- 将结果赋值给变量(或 `_`),防止编译器优化掉调用 +- 在不需要测量的昂贵设置之后使用 `b.ResetTimer()` +- 使用 `b.ReportAllocs()` 或 `-benchmem` 标志跟踪分配情况 + +### 子基准测试 + +```go +func BenchmarkConvert(b *testing.B) { + for _, size := range []int{10, 100, 1000} { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + data := make([]byte, size) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = string(data) + } + }) + } +} +``` + +--- + +## 运行基准测试 + +```bash +# 运行包中的所有基准测试 +go test -bench=. ./... + +# 运行特定基准测试并显示内存统计 +go test -bench=BenchmarkStrconv -benchmem ./... + +# 多次运行以获得统计显著性 +go test -bench=. -benchmem -count=10 ./... +``` + +`-benchmem` 标志报告每次操作的分配次数。`-count` 标志将每个基准测试运行 N 次以获得统计显著性。 + +--- + +## 解读结果 + +``` +BenchmarkStrconv-8 18705042 64.2 ns/op 16 B/op 1 allocs/op +BenchmarkFmtSprint-8 8249536 143.0 ns/op 16 B/op 2 allocs/op +``` + +| 字段 | 含义 | +|------|------| +| `-8` | GOMAXPROCS | +| `18705042` | 迭代次数 | +| `64.2 ns/op` | 每次操作时间 | +| `16 B/op` | 每次操作分配的字节数 | +| `1 allocs/op` | 每次操作的堆分配次数 | + +--- + +## 使用 benchstat 进行比较 + +`benchstat` 对基准测试结果进行统计比较。安装它并将基准测试输出保存到文件: + +```bash +# 安装 benchstat +go install golang.org/x/perf/cmd/benchstat@latest + +# 运行基准测试并保存结果 +go test -bench=. -benchmem -count=10 ./... > old.txt + +# 进行修改后再次运行 +go test -bench=. -benchmem -count=10 ./... > new.txt + +# 比较结果 +benchstat old.txt new.txt +``` + +### 解读 benchstat 输出 + +``` +name old time/op new time/op delta +Strconv-8 64.2ns ± 2% 61.8ns ± 1% -3.74% (p=0.001 n=10+10) +``` + +- **delta**:变化百分比(负数 = 更快) +- **p-value**:统计显著性(p < 0.05 为显著) +- **n**:使用的有效样本数量 + +提示: +- 始终使用 `-count=10` 或更高以获得可靠结果 +- 小的 p 值确认变化是真实的,而非噪声 +- 如果 benchstat 显示 `~`(波浪号),则差异不具有统计显著性 + +--- + +## 来自性能模式的基准测试示例 + +### strconv vs fmt + +| 方式 | 速度 | 分配次数 | +|------|------|---------| +| `fmt.Sprint` | 143 ns/op | 2 allocs/op | +| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op | + +### 重复字节转换 + +```go +func BenchmarkRepeatedConversion(b *testing.B) { + var buf bytes.Buffer + for i := 0; i < b.N; i++ { + buf.Write([]byte("Hello world")) + } +} + +func BenchmarkSingleConversion(b *testing.B) { + var buf bytes.Buffer + data := []byte("Hello world") + for i := 0; i < b.N; i++ { + buf.Write(data) + } +} +``` + +| 方式 | 速度 | +|------|------| +| 重复转换 | 22.2 ns/op | +| 单次转换 | 3.25 ns/op | + +### Slice 容量 + +```go +func BenchmarkNoCapacity(b *testing.B) { + for n := 0; n < b.N; n++ { + data := make([]int, 0) + for k := 0; k < 1000; k++ { + data = append(data, k) + } + } +} + +func BenchmarkWithCapacity(b *testing.B) { + for n := 0; n < b.N; n++ { + data := make([]int, 0, 1000) + for k := 0; k < 1000; k++ { + data = append(data, k) + } + } +} +``` + +| 方式 | 时间(1 亿次迭代) | +|------|------------------------| +| 无容量 | 2.48s | +| 指定容量 | 0.21s | + +--- + +## 使用 pprof 进行性能分析 + +使用 `pprof` 在优化前识别瓶颈。基准测试衡量改进效果;pprof 找到需要改进的地方。 + +### CPU 性能分析 + +```bash +# 从基准测试生成 CPU 分析文件 +go test -bench=BenchmarkHotPath -cpuprofile=cpu.prof ./... + +# 使用 pprof 分析 +go tool pprof cpu.prof +``` + +常用 pprof 命令: + +``` +(pprof) top10 # 按 CPU 时间排列的前 10 个函数 +(pprof) list funcName # 某个函数的带注释源码 +(pprof) web # 浏览器中的交互式图表 +``` + +### 内存性能分析 + +```bash +# 生成内存分析文件 +go test -bench=BenchmarkHotPath -memprofile=mem.prof ./... + +# 分析分配情况 +go tool pprof -alloc_space mem.prof +``` + +### 运行中服务的 HTTP 性能分析 + +```go +import _ "net/http/pprof" + +func main() { + go func() { + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + // ... 应用程序代码 ... +} +``` + +通过 `http://localhost:6060/debug/pprof/` 访问性能分析数据。 + +### 性能分析工作流 + +1. 对疑似热点路径进行**基准测试** +2. 使用 pprof **分析**以确认时间花在了哪里 +3. 使用本技能中的模式进行**优化** +4. **重新基准测试**以用 benchstat 验证改进 +5. **重新分析**以检查是否出现新的瓶颈 + +--- + +## 常见错误 + +### 忽略 b.N + +测试框架会调整 `b.N` 以获得稳定的计时。使用固定迭代次数会产生无意义的结果: + +```go +// 不好:忽略 b.N —— 基准测试框架无法校准 +func BenchmarkFixed(b *testing.B) { + for i := 0; i < 1000; i++ { + doWork() + } +} + +// 好:使用 b.N 作为循环边界 +func BenchmarkCorrect(b *testing.B) { + for i := 0; i < b.N; i++ { + doWork() + } +} +``` + +### 未防止编译器优化消除 + +如果函数调用的结果未被使用,编译器可能会完全优化掉该调用。将结果赋值给包级变量: + +```go +// 不好:编译器可能会优化掉调用 +func BenchmarkElided(b *testing.B) { + for i := 0; i < b.N; i++ { + expensiveFunc() + } +} + +// 好:赋值给包级变量以防止优化消除 +var benchResult int + +func BenchmarkKept(b *testing.B) { + var r int + for i := 0; i < b.N; i++ { + r = expensiveFunc() + } + benchResult = r +} +``` diff --git a/.agents/skills/go-performance/references/STRING-OPTIMIZATION.md b/.agents/skills/go-performance/references/STRING-OPTIMIZATION.md new file mode 100644 index 00000000..eca3bb77 --- /dev/null +++ b/.agents/skills/go-performance/references/STRING-OPTIMIZATION.md @@ -0,0 +1,134 @@ +# 字符串优化模式 + +## strconv vs fmt + +在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快,因为 `fmt` 使用反射并处理任意类型。 + +**不好:** + +```go +for i := 0; i < b.N; i++ { + s := fmt.Sprint(rand.Int()) +} +``` + +**好:** + +```go +for i := 0; i < b.N; i++ { + s := strconv.Itoa(rand.Int()) +} +``` + +**基准测试比较:** + +| 方式 | 速度 | 分配次数 | +|------|------|---------| +| `fmt.Sprint` | 143 ns/op | 2 allocs/op | +| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op | + +常用转换: + +| 任务 | `fmt` | `strconv` | +|------|-------|-----------| +| Int → string | `fmt.Sprint(n)` | `strconv.Itoa(n)` | +| Int64 → string | `fmt.Sprint(n)` | `strconv.FormatInt(n, 10)` | +| Float → string | `fmt.Sprint(f)` | `strconv.FormatFloat(f, 'f', -1, 64)` | +| String → int | — | `strconv.Atoi(s)` | +| Bool → string | `fmt.Sprint(b)` | `strconv.FormatBool(b)` | + +--- + +## 重复的字符串到字节转换 + +不要重复从固定字符串创建字节切片。应该只转换一次并保存结果。 + +**不好:** + +```go +for i := 0; i < b.N; i++ { + w.Write([]byte("Hello world")) +} +``` + +**好:** + +```go +data := []byte("Hello world") +for i := 0; i < b.N; i++ { + w.Write(data) +} +``` + +**基准测试比较:** + +| 方式 | 速度 | +|------|------| +| 重复转换 | 22.2 ns/op | +| 单次转换 | 3.25 ns/op | + +好的版本**快约 7 倍**,因为它避免了每次迭代都分配新的字节切片。 + +--- + +## 字符串拼接 + +根据复杂度选择正确的字符串构建策略。 + +### 简单场景使用 `+` + +```go +key := "projectid: " + p +``` + +`+` 运算符对于少量、固定数量的字符串是高效的。编译器通常可以优化相邻的字符串字面量。 + +### 格式化使用 `fmt.Sprintf` + +```go +// 好:清晰的格式化 +str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst) + +// 不好:使用 + 手动转换 +str := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String() +``` + +当写入 `io.Writer` 时,直接使用 `fmt.Fprintf` 而不是先用 `fmt.Sprintf` 构建临时字符串。 + +### 逐段构建使用 `strings.Builder` + +`strings.Builder` 花费摊销线性时间,而重复使用 `+` 或 +`fmt.Sprintf` 在构建大字符串时花费二次时间: + +```go +b := new(strings.Builder) +for i, d := range digitsOfPi { + fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d) +} +str := b.String() +``` + +### 常量多行字符串使用反引号 + +```go +// 好:原始字符串字面量 +usage := `Usage: + +custom_tool [args]` + +// 不好:使用转义序列拼接 +usage := "" + + "Usage:\n" + + "\n" + + "custom_tool [args]" +``` + +### 策略总结 + +| 方法 | 最佳用途 | 性能 | +|------|---------|------| +| `+` | 少量字符串,简单拼接 | 小 n 时 O(n) | +| `fmt.Sprintf` | 格式化输出 | 较慢,但更清晰 | +| `strings.Builder` | 循环/逐段构建 | 摊销 O(n) | +| `strings.Join` | 连接 slice | O(n) | +| 反引号字面量 | 常量多行文本 | 零开销 | diff --git a/.agents/skills/go-performance/scripts/bench-compare.sh b/.agents/skills/go-performance/scripts/bench-compare.sh new file mode 100755 index 00000000..47f03925 --- /dev/null +++ b/.agents/skills/go-performance/scripts/bench-compare.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.1.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&2 + else + echo "$@" + fi +} + +COUNT=5 +BASELINE="" +SAVE="" +FILTER="." +PACKAGE="" +JSON_OUTPUT=false +BENCHMEM=true +FORCE=false +LIMIT=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;; + -n|--count) COUNT="${2:?error: --count requires a number}"; shift 2 ;; + -b|--baseline) BASELINE="${2:?error: --baseline requires a file path}"; shift 2 ;; + -s|--save) SAVE="${2:?error: --save requires a file path}"; shift 2 ;; + -f|--filter) FILTER="${2:?error: --filter requires a regex}"; shift 2 ;; + --json) JSON_OUTPUT=true; shift ;; + --benchmem) BENCHMEM=true; shift ;; + --no-benchmem) BENCHMEM=false; shift ;; + --force) FORCE=true; shift ;; + --limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;; + -*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;; + *) PACKAGE="$1"; shift ;; + esac +done + +PACKAGE="${PACKAGE:-./...}" + +if ! command -v go &>/dev/null; then + echo "error: 'go' command not found in PATH" >&2 + exit 2 +fi + +if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then + echo "error: --count must be a positive integer, got: $COUNT" >&2 + exit 2 +fi + +if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then + echo "error: --limit must be a non-negative integer, got: $LIMIT" >&2 + exit 2 +fi + +if [[ -n "$BASELINE" && ! -f "$BASELINE" ]]; then + echo "error: baseline file not found: $BASELINE" >&2 + exit 2 +fi + +if [[ -n "$SAVE" && -f "$SAVE" ]] && ! $FORCE; then + echo "error: save target already exists: $SAVE (use --force to overwrite)" >&2 + exit 2 +fi + +HAS_BENCHSTAT=false +if command -v benchstat &>/dev/null; then + HAS_BENCHSTAT=true +fi + +BENCH_ARGS=(-bench "$FILTER" -count "$COUNT" -run '^$') +if $BENCHMEM; then + BENCH_ARGS+=(-benchmem) +fi + +TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bench-XXXXXX.txt") +trap 'rm -f "$TMPFILE"' EXIT + +log "Running benchmarks: go test ${BENCH_ARGS[*]} $PACKAGE" +log "Iterations: $COUNT" +log "" + +GO_EXIT=0 +if $JSON_OUTPUT; then + go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" >&2 || GO_EXIT=$? +else + go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" || GO_EXIT=$? +fi + +BENCH_COUNT=$(grep -cE '^Benchmark' "$TMPFILE" || true) + +TRUNCATED=false +if [[ $LIMIT -gt 0 && $BENCH_COUNT -gt $LIMIT ]]; then + TRUNCATED=true +fi + +if ! $JSON_OUTPUT && $TRUNCATED; then + log "" + log "Note: $BENCH_COUNT benchmark results found, showing first $LIMIT (--limit $LIMIT)" +fi + +if [[ -n "$SAVE" ]]; then + cp "$TMPFILE" "$SAVE" + log "" + log "Results saved to: $SAVE" +fi + +if [[ -n "$BASELINE" ]]; then + log "" + log "=== Comparison with baseline: $BASELINE ===" + log "" + if $HAS_BENCHSTAT; then + if $JSON_OUTPUT; then + benchstat "$BASELINE" "$TMPFILE" >&2 || true + else + benchstat "$BASELINE" "$TMPFILE" || true + fi + else + log "note: install benchstat for statistical comparison:" + log " go install golang.org/x/perf/cmd/benchstat@latest" + log "" + log "--- Baseline ---" + if $JSON_OUTPUT; then + grep -E '^Benchmark' "$BASELINE" >&2 || true + else + grep -E '^Benchmark' "$BASELINE" || true + fi + log "" + log "--- Current ---" + if $JSON_OUTPUT; then + grep -E '^Benchmark' "$TMPFILE" >&2 || true + else + grep -E '^Benchmark' "$TMPFILE" || true + fi + fi +fi + +FINAL_EXIT=0 +if [[ $GO_EXIT -ne 0 ]]; then + FINAL_EXIT=1 + if ! $JSON_OUTPUT; then + log "" + log "error: go test exited with code $GO_EXIT" + fi +elif [[ $BENCH_COUNT -eq 0 ]]; then + FINAL_EXIT=1 + if ! $JSON_OUTPUT; then + log "" + log "error: no benchmarks found matching filter: $FILTER" + fi +fi + +if $JSON_OUTPUT; then + BENCH_OUTPUT=$(<"$TMPFILE") + if $TRUNCATED; then + limited="" + bench_seen=0 + while IFS= read -r line; do + if [[ "$line" =~ ^Benchmark ]]; then + bench_seen=$((bench_seen + 1)) + if [[ $bench_seen -le $LIMIT ]]; then + limited+="$line"$'\n' + fi + else + limited+="$line"$'\n' + fi + done < "$TMPFILE" + BENCH_OUTPUT="$limited" + fi + + escaped_package=$(json_escape "$PACKAGE") + escaped_filter=$(json_escape "$FILTER") + escaped_baseline=$(json_escape "$BASELINE") + escaped_save=$(json_escape "$SAVE") + escaped_output=$(json_escape "$BENCH_OUTPUT") + + printf '{"count":%d,' "$COUNT" + printf '"package":"%s",' "$escaped_package" + printf '"filter":"%s",' "$escaped_filter" + printf '"benchmarks_found":%d,' "$BENCH_COUNT" + printf '"baseline":"%s",' "$escaped_baseline" + printf '"save":"%s",' "$escaped_save" + printf '"exit_code":%d,' "$GO_EXIT" + printf '"output":"%s"' "$escaped_output" + if $TRUNCATED; then + printf ',"truncated":true' + fi + printf '}\n' +fi + +exit $FINAL_EXIT diff --git a/.agents/skills/logstore/SKILL.md b/.agents/skills/logstore/SKILL.md index 8e991a8a..4839c06c 100644 --- a/.agents/skills/logstore/SKILL.md +++ b/.agents/skills/logstore/SKILL.md @@ -1,13 +1,13 @@ --- name: "logstore" -description: "Wavelet 项目专用:当新增或修改日志/分析用途表(访问日志、审计流水、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。" +description: "OpenFlare / Wavelet:当新增或修改日志/分析用途表(节点访问日志、用户访问日志、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。" --- # 日志用途表开发 开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。** -分层与切换协议见 [日志用途表](../../../docs/LOGSTORE.md)。 +设计背景见 [日志存储解耦](../../../docs/design/logstore.md)。 ## 先判定 @@ -16,77 +16,60 @@ description: "Wavelet 项目专用:当新增或修改日志/分析用途表( - 追加写入、几乎不更新单行 - 按时间查询/聚合,允许按保留天数删除 - 关闭 ClickHouse 后仍要能写、能查 -- 不参与用户/配置/任务等事务一致性 +- 不参与网站/节点/证书等事务一致性 -**不要**做成日志表:用户、配置、任务执行、上传元数据、需要事务或强一致的业务实体。这些走主库 `repository`,不要进 `logstore`。 +**不要**做成日志表:Zone、节点、配置版本、任务执行、上传元数据。这些走主库 `repository`。 -当前框架已接入的日志表:`w_user_access_logs`(管理端 API 访问审计)。 +当前日志域: + +| 域 | 接口 | 表 | +| :--- | :--- | :--- | +| 节点访问日志 | `AccessLogStore` | `of_node_access_logs` | +| 可观测 | `ObservabilityStore` | `of_node_metric_snapshots` / `of_node_edge_health` / `of_node_obs_frps` / `of_node_obs_frpc` | +| 用户访问审计 | `UserAccessLogStore` | `w_user_access_logs` | ## 分层 | 层级 | 路径 | 职责 | | :--- | :--- | :--- | -| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里 | -| CH 实现 | `logstore` 委托 `internal/repository/analytics` | 原生 `PrepareBatch` / `ChDB` 查询 | -| 主库实现 | `logstore` GORM | PG(按月分区)与 SQLite(普通表) | -| Model | `internal/model/analytics` | 实体、`TableName`、`InsertColumns`、`BatchInsertSQL`,无 IO | -| 入队 | `internal/apps/` + `batchwriter` | `FlushFunc` 调 `logstore.Active().….BatchInsert` | -| 切换 | `internal/apps/admin/logs` 的 `logs:db_switch` | 冻结写入 → 排空 → 复制 → 翻转 `log_database` | -| 清理 | `logstore.CleanupExpired`,由 `system:cleanup` 调用 | 按库读取保留天数后 `DeleteBefore` | +| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里或 `repository` 门面 | +| CH 实现 | `logstore/clickhouse_store.go` 委托 `analytics` | 原生批量 + 现有聚合 SQL | +| 主库实现 | `logstore/postgres_store.go` | PG(按月分区)与 SQLite(普通表)共用 GORM | +| Model | `internal/model/analytics` | 实体与批量 SQL,无 IO | +| 入队 | `chwriter` / `risk_control` + `batchwriter` | flush 调 logstore `BatchInsert*`;CH 入队经 hooks | +| 切换 | `of_log_db_switch` | 冻结 → `chwriter.Drain` → 逐表复制 → 翻转 | +| 约束 | `logstore/imports_test.go` | apps 禁止 import `repository/analytics` | -`log_database` ∈ {`postgres`,`sqlite`,`clickhouse`},且只能是「随主库」或 ClickHouse:主库为 PG 时日志不能是 SQLite,反之亦然。`log_database` / `log_db_migration` 受保护,禁止管理端手动改。 +`log_database` 只能是「随主库」或 `clickhouse`。`log_database` / `log_db_migration` 受保护。 ## 新增一张日志表 -按顺序做,列名三库必须一致。 - -1. **Model** - 在 `internal/model/analytics/` 定义 struct;实现 `TableName()`;批量写再提供 `InsertColumns()` / `BatchInsertSQL()`。 - -2. **三套 DDL**(`database-migration`) - - ClickHouse:`goose/clickhouse/`,`MergeTree`,`PARTITION BY toYYYYMM(时间列)`。 - - PostgreSQL:`goose/postgres/`,高频表用 `PARTITION BY RANGE (时间列)`,复合主键必须包含分区键。 - - SQLite:`goose/sqlite/`,普通表 + 时间/过滤列索引。 - 不要在 PG/SQLite 上复制 CH 物化视图;聚合在查询时实时算。 - -3. **logstore 接口** - 在对应 Store(现有 `UserAccessLogStore`,或新域自建接口并挂到 `Store`)补齐至少: - - 写入:`BatchInsert`(flush 目标;内调 `ensureWritable`) - - 查询:业务需要的 List/Count/聚合 - - 迁移:`ListForMigration(afterID, limit)`、`MigrationRange`、`DeleteAll`、`EnsurePartitions`(PG 按月预建,CH/SQLite no-op) - - 清理:`DeleteBefore(cutoff)`、`DropEmptyPartitions`、`DropExpiredPartitions`(仅 PG;CH/SQLite no-op) - -4. **双实现** - - CH:委托 `analyticsrepo`,零额外查询路径。 - - GORM:PG/SQLite 共用一套;方言 SQL 只放小函数(如按日 `to_char` / `strftime`)。零值 `id` 落库前用 `idgen.NextUint64ID()`。 - -5. **`buildStore`** - 在 `provider.go` 的 CH / GORM 分支同时挂上新域。 - -6. **写入** - apps 用独立 `batchwriter` 实例;`FlushFunc` → `logstore.Active(ctx)` → `BatchInsert`。禁止 `analyticsrepo.BatchInsert`、禁止 `db.ChConn`。迁移任务调用域的 `Drain`(等队列空一个 flush 周期,不要 `Stop` writer)。 - -7. **切换任务** - 在 `copy*` 流程增加该表:`DeleteAll` 目标 → `MigrationRange` + `EnsurePartitions` → 按 id 分页复制。不要改切换协议(仍冻结写入、源数据不删、成功才翻转)。 - -8. **清理** - `CleanupExpired`:PG 先 `DropExpiredPartitions`(整月过期分区),再 `DeleteBefore`(边界月),最后 `DropEmptyPartitions`。保留天数用已有 `log_retention_days_*`。apps 禁止 import `repository/analytics`(`imports_test.go`)。 +1. **Model**(`internal/model/analytics`):`TableName` + `InsertColumns` / `BatchInsertSQL`。 +2. **三套 DDL**:CH `MergeTree` + `toYYYYMM`;PG `PARTITION BY RANGE(时间列)`(主键含分区键);SQLite 普通表。不要在主库建 CH 物化视图,聚合实时算。 +3. **挂到已有域或新接口**:能进 `AccessLogStore` / `ObservabilityStore` / `UserAccessLogStore` 就不要再拆包。新域才新增接口并放进 `Store`。 +4. **方法最少集**:`BatchInsert`(含 `ensureWritable`)、业务查询、`ListForMigration`、`MigrationRange`、`DeleteAll`、`DeleteBefore`、`EnsurePartitions`(仅 PG 预建)。 +5. **双实现**:CH 委托 `analyticsrepo`;GORM 共用一套,方言 SQL 放 `dialect_*.go`。零值 id 用 `idgen.NextUint64ID()`。 +6. **`buildStore`**:CH / GORM 两分支都挂上。 +7. **写入**:独立 `batchwriter`;`FlushFunc` → `logstore.Active`。节点日志/可观测走 `SetAccessLogHooks` / `SetObservabilityHooks`,不要让 apps 碰 `ChConn`。 +8. **切换任务**:`clearTarget` + `copy*` 增加该表;源数据不删,失败不翻转。 +9. **清理**:访问类走 `log_retention_days_*`;性能指标走 `metric_retention_days`。不要擅自共用错误的 TTL。 +10. **import-lint**:apps 新增对 `analytics` 或 `infra/persistence`(`batchwriter`/`idgen` 除外)的 import 必须失败。 ## 禁止 -- apps 直接 `import` `internal/repository/analytics` 或 `db.ChConn` / `db.ChDB` 做日志读写 -- 只建 CH 表、不建 PG/SQLite 回落 -- 在 Handler 里逐条 `PrepareBatch` + `Send` -- 把业务表「顺便」放进 logstore 以便关 CH -- 管理端 API 改 `log_database` / `log_db_migration` +- apps 直连 `analyticsrepo` / `db.ChConn` / `db.ChDB` 做日志读写 +- 只建 CH、不建主库回落 +- Handler 内逐条 `PrepareBatch` +- 业务表塞进 logstore +- 管理端改 `log_database` / `log_db_migration` ## 验证 ```bash go test ./internal/repository/logstore ./internal/repository/analytics -go test ./internal/apps/admin/logs ./internal/apps/risk_control ./internal/platform/bootstrap -make swagger # 若改了状态/查询 API +go test ./internal/apps/openflare/... ./internal/apps/admin/logs ./internal/apps/admin/status +make swagger make code-check ``` -对照:`w_user_access_logs` 的 model、三库 goose、`logstore` GORM/CH、`risk_control.InitLogWriter`、`logs.LogDBSwitchHandler`、`system:cleanup`。 +对照:`of_node_access_logs` 或 `w_user_access_logs` 的 model、三库 goose、`logstore` 双实现、`chwriter`/`risk_control` flush、`LogDBSwitchHandler`。 diff --git a/.agents/skills/new-api/SKILL.md b/.agents/skills/new-api/SKILL.md index 5093307b..a3451a53 100644 --- a/.agents/skills/new-api/SKILL.md +++ b/.agents/skills/new-api/SKILL.md @@ -1,216 +1,146 @@ --- name: "new-api" -description: "Wavelet 项目专用:当新增或修改业务 API、Handler、服务层逻辑、插件路由注册时必须使用。本技能指导基于 Cordis 插件的 API 架构、ctx.Router() 声明式路由注册、Handler/Service 分层、Swagger 与质量门禁。" +description: "Wavelet 项目专用:当新增或修改自定义业务 API、新增业务路由、新增 service 层核心逻辑时必须使用。本技能指导包职责划分、推荐文件结构、路由解耦、Swagger 文档生成与质量门禁验证。" --- -# 新增业务 API 开发与路由注册规范 (Cordis 插件化架构) +# 新增业务 API 开发与路由注册规范 -本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行 HTTP API 接口开发与路由注册的唯一指导规范。 +本技能是 Wavelet 项目接口开发与路由注册的唯一指导规范。在开发任何新接口前,请严格按照本指南进行架构决策与路由注册。 --- -## 1. 核心架构哲学:插件自包含 (Self-Contained Plugins) +## 核心路由准则与防线 (Routing Governance & Guardrails) -在 Cordis 架构中,**业务 API 不再集中在旧的 `internal/router/` 或 `internal/apps/` 目录**。 -所有业务能力均封装为**高内聚、扁平自包含的插件 (Plugin)**。每个插件自主管理自身的路由声明、中间件挂载、服务逻辑、数据模型与迁移脚本。 +Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则: -### 插件目录推荐结构 (`backend/plugins/domain//` 或下游 `custom_plugins//`) +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 下的标准自定义业务接口) + +--- + +## 路由归属判定表 (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` 的业务模块)时,建议采用以下标准文件结构: -#### 模式 1:极简单文件自包含(适用于极简微型插件 / 单一实体 / <500行) ```text -backend/plugins/domain/demo/ -├── plugin.go # 插件入口:实现 core.Plugin,通过 ctx.Router() 挂载路由 -├── handlers.go # HTTP 控制器单文件:参数校验、上下文提取、调用 Service、信封响应 -├── service.go # 业务服务层单文件:纯 Go 逻辑,仅依赖 context.Context -├── repository.go # 数据库访问层单文件:GORM 查询、SQL 防注入与转义 -├── models.go # GORM 数据实体定义(自带表前缀)与 DTO -├── errs.go # 模块内错误常量定义(camelCase 字符串) -└── migrations/ # 专属嵌入式 Goose SQL 迁移脚本 - └── 20260827000001_create_demo_table.sql -``` -> ⚠️ **严禁**:当需要拆分多个 Handler/Service 文件时,**严禁在根目录平铺 `handlers_*.go`、`service_*.go`、`repository_*.go` 等前缀文件**,必须立即采用模式 2(独立子包分层)。 - -#### 模式 2:标准独立子包分层架构(适用于标准/中大型业务插件 / 官方推荐标准) -```text -backend/plugins/domain/order/ -├── plugin.go # 插件根入口:实现 core.Plugin,装配各子包并向 Cordis 注册 -│ -├── handler/ # package handler:HTTP 控制器与路由声明(或 controller/) -│ ├── router.go # 路由组声明与中间件挂载 -│ └── order.go # 订单 Handler(直接以业务命名,禁止 handlers_order.go) -│ -├── service/ # package service:业务逻辑层(用例编排、事件发布) -│ ├── service.go # Service 接口与组装 -│ └── order.go # 订单业务用例实现(直接以业务命名,禁止 service_order.go) -│ -├── repository/ # package repository:数据持久化访问层 (DAL) -│ ├── repository.go # 仓储抽象与通用工厂 -│ └── order.go # 订单仓储实现(直接以业务命名,禁止 repository_order.go) -│ -├── model/ # package model (或 models/):纯数据实体与 DTO(无外部依赖) -│ ├── entity.go # 数据库映射实体 (TableName() 带插件专属前缀) -│ ├── dto.go # 请求与响应 DTO -│ └── events.go # 领域事件定义 -│ -├── errs/ # package errs:错误常量与错误码 (或根目录 errs.go) -│ └── errs.go -│ -└── migrations/ # 专属嵌入式 Goose SQL 迁移脚本 - └── 20260827000001_create_orders_table.sql +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 # [新建] 存放模块特有的业务错误常量定义(可选) ``` --- -## 2. 插件契约与路由注册流程 +## 核心开发步骤 (Step-by-Step Flow) -### 步骤 1:定义插件结构并实现 `core.Plugin` +### 步骤 1:数据库定义与迁移 +如果自定义功能涉及新表或字段,请参考 [database-migration](../database-migration/SKILL.md) 技能,在 `internal/infra/persistence/migrator/goose/` 目录下编写迁移文件,在 `internal/model/` 中定义 GORM 实体(无 CRUD / 无 DB 访问),并在 `internal/repository/` 中实现数据访问(**repository 为唯一持久化入口**)。 -插件必须实现 `core.Plugin` 接口: +### 步骤 2:在模块内实现业务逻辑 (`logics.go` / `service.go`) +业务逻辑逻辑应当实现于 `internal/apps/custom/` 目录下: +- **优先使用纯函数(`logics.go`)**:定义接收 `context.Context` 且不依赖 `*gin.Context` 的函数,易于单元测试与 Worker 复用。参考 `internal/apps/user/logics.go`。 +- **有状态服务(`service.go`)**:若需注入依赖(如 DB 连接、外部客户端等),可定义 Service 结构体和构造函数。 +- **跨模块副作用(推送、任务监听等)**:核心业务代码通过 `internal/listener` 发射域事件,禁止直接 `import` push 模块;装配在 `internal/platform/bootstrap` 完成(参见 `push-notification` skill)。 -```go -package order +### 步骤 3:编写 HTTP Handler (`routers.go`) +在 `internal/apps/custom/routers.go` 中编写 Handler: +- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。 +- 负责提取 Session / 用户身份。 +- 调用业务逻辑层,并使用 `OpenFlare/internal/shared/response` 统一返回响应: + - 成功时返回:`response.OK(data)` 或 `response.OKNil()` + - 失败时返回:`response.Err(msg)` +- 编写规范的 Swagger 注释。 -import ( - "github.com/Rain-kl/Wavelet/core" - "github.com/Rain-kl/Wavelet/core/contracts" -) - -type Plugin struct { - svc *OrderService -} - -func (p *Plugin) Name() string { - return "domain.order" -} - -func (p *Plugin) Apply(ctx *core.Context) error { - // 1. 初始化业务 Service - p.svc = NewOrderService(ctx) - - // 2. 如果需要对外暴露服务,注入 IoC 容器供其他插件消费 - // core.Provide[contracts.OrderService](ctx, p.svc) - - // 3. 注册 HTTP 路由与中间件 - p.registerRoutes(ctx) - - return nil -} -``` - -### 步骤 2:通过 `ctx.Router()` 挂载路由组与中间件 - -通过微内核扩展点 `ctx.Router()` 声明式挂载语义化路由与鉴权中间件: - -```go -func (p *Plugin) registerRoutes(ctx *core.Context) { - // 获取认证服务提供的标准中间件(若需要) - authSvc, _ := core.Inject[contracts.AuthService](ctx) - - // 创建带语义化版本前缀的路由组 - group := ctx.Router().Group("/api/v1/orders") - if authSvc != nil { - group.Use(authSvc.RequireAuthMiddleware()) - } - - // 绑定 Handler - group.GET("", p.handleListOrders) - group.POST("", p.handleCreateOrder) - group.GET("/:id", p.handleGetOrderDetail) - group.PUT("/:id/cancel", p.handleCancelOrder) -} -``` - -### 步骤 3:公开接口与白名单注册 (`RegisterWhitelist`) - -如果插件包含**无需登录**的公开端点(如登录、注册、人机校验、Webhooks、公开状态查询),必须在 `Apply` 中主动注册到白名单: - -```go -func (p *Plugin) Apply(ctx *core.Context) error { - // 注册公开接口白名单(支持精确路径与通配符如 /api/v1/oauth/*) - ctx.Router().RegisterWhitelist( - "/api/v1/public/ping", - "/api/v1/public/webhook/*", - ) - - // 或在子路由组中相对注册: - publicGroup := ctx.Router().Group("/api/v1/public") - publicGroup.RegisterWhitelist("/status", "/docs/*") - ... -} -``` -> 💡 **防线机制**:注册到白名单的路由在经过 `auth.RequireAuthMiddleware()` 时将自动放行,彻底消除全局/组级鉴权中间件引起的 401 Unauthorized 误拦截。 +### 步骤 4:在自定义包中注册路由并委派 +根据 **路由归属判定表**,在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 或 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中编写注册代码,将路由路径绑定到步骤 3 中编写的 Handler。 --- -## 3. Handler 与 Service 职责划分 +## 质量验证门禁 (Quality Gates) -### Handler 规范 (`handlers.go`) -Handler 负责协议接入层: -1. 参数绑定:使用 `c.ShouldBindJSON` 或 `c.ShouldBindQuery`。 -2. 提取当前登录用户信息(如 `oauth.GetCurrentUser(c)`)。 -3. 调用底层纯函数或 Service 逻辑。 -4. 错误处理:统一使用 `response.Abort*` 系列函数中断请求,禁止直接 `c.JSON(status, response.Err(...))`。 -5. 成功响应:使用 `c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。 -6. 编写完整的 Swagger / OpenAPI 注释。 - -```go -// @Summary 创建订单 -// @Description 创建一笔新的业务订单 -// @Tags Order -// @Accept json -// @Produce json -// @Param request body CreateOrderRequest true "创建订单参数" -// @Success 200 {object} response.Envelope{data=OrderDTO} "创建成功" -// @Failure 400 {object} response.Envelope "参数绑定失败" -// @Failure 401 {object} response.Envelope "未授权" -// @Router /api/v1/orders [post] -func (p *Plugin) handleCreateOrder(c *gin.Context) { - var req CreateOrderRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, errs.ErrBindParamsFailed) - return - } - - user, ok := oauth.GetCurrentUser(c) - if !ok { - response.AbortUnauthorized(c, errs.ErrUnauthorized) - return - } - - order, err := p.svc.CreateOrder(c.Request.Context(), user.ID, req) - if err != nil { - // 底层已记录日志,此处根据业务错误码响应 - response.AbortInternal(c, errs.ErrCreateOrderFailed) - return - } - - c.JSON(http.StatusOK, response.OK(order)) -} -``` - -### Service / Logics 规范 (`service.go`) -1. 纯 Go 逻辑,第一参数为 `ctx context.Context`,返回 `(result, error)`。 -2. **严禁依赖 `*gin.Context`** 或调用 `c.JSON`/`Abort*`。 -3. 数据库操作通过 `ctx.DB()` 或受 Trace 保护的 DB 实例完成。 -4. 缓存操作通过 `ctx.Cache()` 完成。 - ---- - -## 4. 跨插件依赖与防线 (Guardrails) - -1. **严禁跨插件 import 内部实现**:插件之间不得直接 import 对方包中的具体结构体或私有逻辑。 -2. **面向契约编程**:跨插件调用一律在 `core/contracts/` 中定义 Interface,通过 `core.Provide` 注册、`core.Inject` 或 `ctx.Using` 延迟解析。 -3. **事件驱动通知**:涉及跨域状态联动(如用户注册成功、订单支付完成),统一使用 `ctx.Events().Emit(...)` 广播领域事件,由订阅方自愿监听,消除循环依赖。 - ---- - -## 5. 质量验证门禁 - -在完成 API 开发后,必须依次运行以下命令: -```bash -make license # 确保新文件具有开源许可头 -make swagger # 重新生成 Swagger 文档 -make format # 代码自动格式化 -make code-check # 静态代码质量检查 (golangci-lint) -go test ./plugins/... # 运行插件单元测试 -``` +每次新增或修改接口后,必须运行并验证以下各项: +1. **自动授权许可**:`make license`(新增 Go 文件时自动添加许可头) +2. **重新生成 Swagger 文档**:`make swagger`(若有 Swagger 注释修改) +3. **静态代码及风格检查**:`make code-check`(确保通过 golangci-lint 和前端 TS 检查) +4. **自动化单元测试**:`go test ./...`(确保所有测试 100% 通过) diff --git a/.agents/skills/new-api/references/handler_example.go b/.agents/skills/new-api/references/handler_example.go index 54d71494..38bfa78b 100644 --- a/.agents/skills/new-api/references/handler_example.go +++ b/.agents/skills/new-api/references/handler_example.go @@ -6,50 +6,53 @@ package references import ( "net/http" - "github.com/Rain-kl/Wavelet/pkg/response" + "OpenFlare/internal/service" + "OpenFlare/internal/util" "github.com/gin-gonic/gin" ) -// createChannelRequest 客户端请求体 DTO -type createChannelRequest struct { - Name string `json:"name" binding:"required,min=1,max=100"` +// customRequest 客户端请求体 DTO +type customRequest struct { + Payload string `json:"payload" binding:"required,min=1,max=100"` } -// createChannelResponse API 响应体 DTO -type createChannelResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` +// customResponse API 响应体 DTO +type customResponse struct { + Result string `json:"result"` } -// CreateChannel 示例:插件内 HTTP Handler(位于 plugins/domain/channel/handlers.go) -// @Summary 创建频道 -// @Description 示例:语义路径下的业务接口 -// @Tags channel +// HandleCustomBusiness 示例 API Handler +// @Summary 示例定制业务接口 +// @Description 接收数据载荷,调用 Service 执行核心逻辑,并返回统一格式的 JSON 结果。 +// @Tags custom // @Accept json // @Produce json -// @Param request body createChannelRequest true "业务请求参数" -// @Success 200 {object} response.Any{data=createChannelResponse} "操作成功" -// @Failure 400 {object} response.Any "参数错误" -// @Failure 401 {object} response.Any "未登录" -// @Router /api/v1/channels [post] -func CreateChannel(c *gin.Context) { - var req createChannelRequest +// @Param request body customRequest true "业务请求参数" +// @Success 200 {object} util.ResponseAny{data=customResponse} "操作成功" +// @Router /api/v1/custom/business [post] +func HandleCustomBusiness(c *gin.Context) { + // 1. 参数绑定与校验 + var req customRequest if err := c.ShouldBindJSON(&req); err != nil { - response.AbortBadRequest(c, "参数校验失败") + c.JSON(http.StatusBadRequest, util.Err("参数校验失败:载荷不能为空且在 1-100 字符内")) return } - // 从请求上下文中提取认证用户 + // 2. 模拟获取当前上下文与已登录用户(例如从 Session 中提取) + // 通常结合 oauth.LoginRequired() 等中间件使用 userID := int64(9527) - result, err := CreateChannelLogic(c.Request.Context(), userID, req.Name) + // 3. 实例化业务 Service 并调用核心逻辑 + // 注意传入 c.Request.Context() 以正确传递 OpenTelemetry Tracing 等上下文信息 + svc := service.NewCustomService() + resText, err := svc.ProcessBusinessData(c.Request.Context(), userID, req.Payload) if err != nil { - response.AbortBadRequest(c, err.Error()) + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } - c.JSON(http.StatusOK, response.OK(createChannelResponse{ - ID: result.ID, - Name: result.Name, + // 4. 返回符合外层形状规范 { "error_msg": "", "data": ... } 的统一成功响应 + c.JSON(http.StatusOK, util.OK(customResponse{ + Result: resText, })) } diff --git a/.agents/skills/new-api/references/logics_example.go b/.agents/skills/new-api/references/logics_example.go index c23959c9..7d6c726a 100644 --- a/.agents/skills/new-api/references/logics_example.go +++ b/.agents/skills/new-api/references/logics_example.go @@ -8,30 +8,25 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/pkg/logger" + "OpenFlare/pkg/logger" "go.uber.org/zap" ) -// channelCreated 示例业务返回值(真实代码可用 model 或专用 DTO) -type channelCreated struct { - ID int64 - Name string -} - -// CreateChannelLogic 示例:插件内业务纯函数(位于 plugins/domain/channel/logics.go) -// 接收 context.Context,不依赖 gin.Context,便于单测与 Worker 复用。 -func CreateChannelLogic(ctx context.Context, userID int64, name string) (*channelCreated, error) { - if name == "" { - return nil, errors.New("name cannot be empty") +// ProcessLocalBusiness 示例的模块内部闭环业务逻辑 +// 1. 存放在 apps/custom/logics.go 下,遵循纯 Go 规范,不强依赖 gin.Context,以便逻辑清晰和便于单元测试。 +// 2. 用于当前应用模块内的简单业务或通用过程。 +func ProcessLocalBusiness(ctx context.Context, userID int64, param string) (string, error) { + if param == "" { + return "", errors.New("param cannot be empty") } - logger.Info(ctx, "creating channel", + logger.Info(ctx, "processing local business inside apps/custom/logics", zap.Int64("user_id", userID), - zap.String("name", name), + zap.String("param", param), ) - return &channelCreated{ - ID: 1, - Name: fmt.Sprintf("%s (by %d)", name, userID), - }, nil + // 执行轻量级、无需跨模块/多入口复用的本地计算或模型操作 + result := fmt.Sprintf("Processed local logic for user %d: %s", userID, param) + + return result, nil } diff --git a/.agents/skills/new-api/references/service_example.go b/.agents/skills/new-api/references/service_example.go index 70fe4d47..9707c016 100644 --- a/.agents/skills/new-api/references/service_example.go +++ b/.agents/skills/new-api/references/service_example.go @@ -8,33 +8,38 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/pkg/logger" + "OpenFlare/pkg/logger" "go.uber.org/zap" ) -// ChannelService 示例有状态 Service(位于 plugins/domain/channel/service.go) -// 需要注入 DB/缓存时使用;简单逻辑优先 logics.go 纯函数。 -type ChannelService struct { +// CustomService 示例业务 Service 结构体(通常放在 internal/apps/custom/service.go 中) +type CustomService struct { + // 这里可以注入数据库连接、配置对象或者其他基础服务的客户端 // 例如:db *gorm.DB } -// NewChannelService 构造函数 -func NewChannelService() *ChannelService { - return &ChannelService{} +// NewCustomService 创建 CustomService 实例的构造函数 +func NewCustomService() *CustomService { + return &CustomService{} } -// Create 核心业务:首位参数必须是 context.Context;禁止依赖 Gin。 -func (s *ChannelService) Create(ctx context.Context, userID int64, name string) (int64, error) { - if name == "" { - return 0, errors.New("name cannot be empty") +// ProcessBusinessData 演示核心业务处理逻辑的 Service 方法 +// 1. 首位参数必须是 context.Context,以传播链路追踪 (OTel) 和超时控制。 +// 2. 方法签名应该只包含纯 Go 的参数与返回值,禁止导入 Gin 或与 HTTP 相关的协议依赖。 +// 3. 将可能发生的核心异常通过 error 返回给上层,而不是在这一层转换成 HTTP 状态码。 +func (s *CustomService) ProcessBusinessData(ctx context.Context, userID int64, payload string) (string, error) { + if payload == "" { + return "", errors.New("payload cannot be empty") } - logger.Info(ctx, "channel service create", + // 模拟执行业务逻辑... + logger.Info(ctx, "processing custom business data in service", zap.Int64("user_id", userID), - zap.String("name", name), + zap.String("payload", payload), ) - // DB 事务、远程调用等 - _ = fmt.Sprintf("user=%d name=%s", userID, name) - return 1, nil + // 这里可以包含数据库读写、事务控制、或者远程 API 调用等复杂逻辑。 + result := fmt.Sprintf("Success processed data for user %d: %s", userID, payload) + + return result, nil } diff --git a/.agents/skills/new-async-task/SKILL.md b/.agents/skills/new-async-task/SKILL.md index db6651ff..d5d7463a 100644 --- a/.agents/skills/new-async-task/SKILL.md +++ b/.agents/skills/new-async-task/SKILL.md @@ -1,157 +1,122 @@ --- name: "new-async-task" -description: "Wavelet 项目专用:新增或修改基于 Cordis 插件的 Asynq 异步任务、后台 Worker 消费处理器、Cron 定时调度任务与任务执行追踪时必须使用。" +description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台任务、定时任务、任务元数据、TaskHandler、TaskParam、PayloadValidator、AppendLog、任务重试、任务执行记录或 Admin 任务 API 时必须使用。" --- -# 异步任务与定时调度开发规范 (Cordis 插件化架构) +# 异步任务开发 -本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行 Asynq 异步后台任务与 Cron 定时调度开发的唯一指导规范。 +开始前阅读根目录 `AGENTS.md`。只修改任务相关链路,遵守项目路由、日志、数据库迁移和质量门禁要求。 ---- +## 开始前 -## 1. 核心架构:插件内自包含任务声明 +按任务范围检查当前实现: -在 Cordis 架构中,后台 Worker 消费与定时调度**不再集中在中心化的注册表**,而是由各个业务插件在自身的 `Apply` 方法中通过微内核扩展点直接声明。 +- `internal/infra/task/handler.go`:`TaskHandler`、`TaskResult`、`PayloadValidator` +- `internal/infra/task/meta.go`:`TaskMeta`、`TaskParam` +- `internal/infra/task/executor.go`:下发、执行、日志、重试、`OnTaskCompleted` 订阅 +- `internal/infra/task/handlers/register.go`:Handler 和元数据注册(由 bootstrap 调用) +- `internal/platform/bootstrap/bootstrap.go`:任务注册与进程级装配入口 +- `internal/infra/task/worker/worker.go`:Worker 路由和队列 +- `internal/infra/task/scheduler/scheduler.go`:定时调度 +- `internal/apps/admin/task/routers.go`:Admin 任务 API +- `internal/model/task_execution.go`:执行记录实体与 DTO +- `internal/repository/task_execution.go`:执行记录和日志持久化 -### 扩展点矩阵 +需要模板时阅读 [references/CODE-EXAMPLES.md](references/CODE-EXAMPLES.md)。 -| 扩展点方法 | 说明 | 适用场景 | -| :--- | :--- | :--- | -| `ctx.Task().Register(pattern, handler, opts...)` | 注册 Asynq 任务类型与消费处理器 | 异步耗时计算、队列任务、通知外发 | -| `ctx.Schedule().RegisterCron(spec, taskType, payload)` | 注册 Cron 表达式定时调度任务 | 周期统计、定时清理、健康检查 | +## 实现要求 ---- +### 任务定义 -## 2. 异步任务开发全流程 +- 在 `internal/apps//tasks.go` 定义任务类型、Admin 任务类型和 `TaskMeta`。 +- Asynq 任务类型使用 `:` 格式。 +- 完整设置 `Type`、`AsynqTask`、`Name`、`Description`、`MaxRetry`、`Queue`、`Retryable`。 +- 有参数任务必须定义 payload struct。 +- `TaskParam.Name` 必须与 payload JSON tag 一致。 +- `TaskParam` 只描述前端表单,不代替服务端校验。 -### 步骤 1:定义任务 Payload 结构与类型常量 +### Handler -在插件内(如 `backend/plugins/domain/order/tasks.go`): +- Handler 必须实现 `task.TaskHandler`。 +- 有参数任务必须实现 `task.PayloadValidator`,负责校验和标准化 Admin 下发参数。 +- `Execute` 必须再次解析 payload;不要假设入口一定经过 Admin 校验。 +- 成功返回 `&task.TaskResult{Message: ..., Detail: ...}`。 +- 失败返回 error,由任务框架处理状态和重试。 +- 不要吞掉关键错误。 +- 持久化只通过 `internal/repository/`(唯一入口);业务编排放模块内 `logics.go` / `service.go`。`internal/model` 仅实体/DTO,禁止 CRUD 与 DB 访问。 -```go -package order +### 注册 -import ( - "context" - "encoding/json" - "time" +- 在 `internal/infra/task/handlers/register.go` 同时注册 Handler 和 `TaskMeta`。 +- 不要在其他位置单独注册任务。 +- **禁止**在业务包 `routers.go` 或 `init()` 中调用 `task.RegisterHandler`;统一由 `bootstrap.RegisterTasks()` → `taskhandlers.Register()` 在进程启动时装配。 +- 任务完成钩子(如 push 通知)通过 `task.OnTaskCompleted` 注册,在 `bootstrap.RegisterTaskListeners()` 中装配(Worker/`all` 进程)。 - "github.com/hibiken/asynq" -) +### 进程装配分工 -const ( - TaskTypeOrderTimeoutCancel = "order:timeout_cancel" -) +| 进程 | 注册入口 | +| :--- | :--- | +| `api` | `cmd/api.go` → `bootstrap.RegisterAPI()`(含 `RegisterTasks`) | +| `worker` | `worker.StartWorker()` → `bootstrap.RegisterWorker()`(含 `RegisterTasks` + `RegisterTaskListeners`) | +| `scheduler` | `scheduler.StartScheduler()` → `bootstrap.RegisterScheduler()` | +| `all` | `cmd/all.go` → `bootstrap.RegisterAll()` | -// OrderTimeoutPayload 定义任务入参 -type OrderTimeoutPayload struct { - OrderID string `json:"order_id"` - Reason string `json:"reason"` - CreatedAt int64 `json:"created_at"` -} -``` +所有 `Register*` 使用 `sync.Once`,重复调用安全。 -### 步骤 2:实现任务执行处理器 (Handler) +### 测试 -Handler 必须接受 `ctx context.Context, t *asynq.Task`,返回 `error`: +- 依赖已注册任务类型或 Handler 的测试(如 `internal/apps/admin/task/routers_test.go`),必须在 setup 中显式调用 `bootstrap.RegisterTasks()`。 +- 不得依赖 `init()` 副作用或 import 链触发注册。 -```go -func (p *Plugin) handleOrderTimeoutCancel(ctx context.Context, t *asynq.Task) error { - var payload OrderTimeoutPayload - if err := json.Unmarshal(t.Payload(), &payload); err != nil { - return err // 反序列化失败,直接中断 - } +## 日志要求 - // 记录任务日志 - // task.AppendLog(ctx, "开始处理订单超时关单: order_id=%s", payload.OrderID) +- 在 `TaskHandler.Execute` 中使用 `task.AppendLog(ctx, format, args...)`。 +- 记录任务开始、参数摘要、批次进度、关键状态、可继续错误和完成摘要。 +- 批量处理按批次记录;禁止为大循环中的每条数据写日志。 +- 不要直接修改任务日志的 Redis key 或 `w_task_executions.log`。 - // 执行业务逻辑 - if err := p.svc.CancelTimeoutOrder(ctx, payload.OrderID, payload.Reason); err != nil { - // 返回 error 触发 Asynq 框架自动重试 - return err - } +日志框架约束: - return nil -} -``` +- 执行状态实时写入数据库:`pending`、`running`、`succeeded`、`failed`。 +- 实时日志写入 Redis,每个任务最多保留最近 1000 行。 +- Redis 日志 TTL 为 24 小时,每次追加时刷新。 +- 查询时优先返回 Redis 日志,Redis 不存在时读取数据库。 +- 任务成功或自动重试耗尽后,将日志写入数据库并删除 Redis 缓冲。 +- 自动重试期间保留同一 taskID 的 Redis 日志。 -### 步骤 3:在插件 `Apply` 中注册任务与定时调度 +## 重试要求 -```go -func (p *Plugin) Apply(ctx *core.Context) error { - // 1. 注册异步任务处理器 - ctx.Task().Register( - TaskTypeOrderTimeoutCancel, - p.handleOrderTimeoutCancel, - extpoints.WithTaskRetry(3), - extpoints.WithTaskTimeout(5*time.Minute), - ) +- Handler 返回 error 以触发 Asynq 自动重试。 +- 不要在 Handler 内自行实现重复重试循环。 +- Admin 手动重试只允许: + - 原任务状态为 `failed` + - `Retryable=true` + - `RetryCount < MaxRetry` +- 修改重试行为时同时检查: + - `internal/infra/task/executor.go` + - `internal/model/task_execution.go` + - `internal/apps/admin/task/routers.go` + - 前端任务执行列表 - // 2. 注册定时调度任务 (例如每天凌晨 2 点执行汇总) - ctx.Schedule().RegisterCron( - "0 2 * * *", - "order:daily_settlement", - map[string]any{"scope": "all"}, - ) +## 定时任务 - return nil -} -``` +- 默认定时任务必须通过 Goose SQL 迁移写入 `schedules`。 +- PostgreSQL 和 SQLite 迁移必须同时提供。 +- 初始化 SQL 必须幂等。 +- 涉及迁移时使用 `database-migration` skill。 -### 步骤 4:在业务逻辑中投递异步任务 +## Admin API -当业务需要下发延迟或异步任务时: +- Handler 放在现有 Admin task 模块或 `internal/apps/admin//`。 +- 路由只在 `internal/router/router.go` 注册。 +- 响应保持 `{ "error_msg": "", "data": ... }`。 +- 分页数据保持 `{ "total": 0, "results": [] }`。 +- Swagger 注释必须完整;API 变化后运行 `make swagger`。 -```go -func (s *OrderService) EnqueueTimeoutCheck(ctx context.Context, orderID string) error { - payloadBytes, _ := json.Marshal(OrderTimeoutPayload{ - OrderID: orderID, - Reason: "15分钟未支付自动关单", - CreatedAt: time.Now().Unix(), - }) +## 前端 - task := asynq.NewTask( - TaskTypeOrderTimeoutCancel, - payloadBytes, - asynq.ProcessIn(15*time.Minute), // 延迟 15 分钟执行 - asynq.MaxRetry(3), - ) - - // 投递到任务客户端 - _, err := s.taskClient.EnqueueContext(ctx, task) - return err -} -``` - ---- - -## 3. 运行切面透明性 (Profile Transparency) - -Cordis 微内核支持多种启动切面(`api`、`worker`、`schedule`、`all`): -- 插件开发者**无需在插件代码中编写 `if mode == "worker"` 分支**。 -- 插件只需在 `Apply` 中把任务与调度注册进 `Context`。 -- 当进程以 `worker` 切面启动时,微内核的 `driver_asynq_worker` 驱动会自动拾取并监听已注册的任务。 -- 当进程以 `schedule` 切面启动时,`driver_asynq_cron` 驱动会自动启动调度器引擎。 - ---- - -## 4. 任务日志与重试规范 - -1. **日志记录**: - - 记录任务启动参数摘要、分批处理进度及最终完成统计。 - - 大循环处理中应按批次记录日志,禁止每条数据单独打日志刷屏。 -2. **重试机制**: - - Handler 返回 error 即自动触发 Asynq 重试策略。 - - 禁止在 Handler 内部编写裸 `for` 死循环重试。 -3. **幂等性保障**: - - 任务由于网络波动或超时可能被重复消费,业务操作必须实现幂等保护(如基于订单状态机检查或分布式锁 `ctx.DistLock()`)。 - ---- - -## 5. 质量验证 - -```bash -make format -make code-check -go test ./plugins/... -``` +- 仅任务元数据变化时,优先复用现有动态任务表单,不新增页面。 +- API 调用必须通过 `frontend/lib/services/`。 +- 修改 shadcn/ui 时使用 `shadcn` skill。 +- 不使用 `any`。 +- 页面根容器使用 `w-full`,不添加页面级 `max-w-*`。 diff --git a/.agents/skills/new-async-task/references/CODE-EXAMPLES.md b/.agents/skills/new-async-task/references/CODE-EXAMPLES.md index c19a525b..d07bfbca 100644 --- a/.agents/skills/new-async-task/references/CODE-EXAMPLES.md +++ b/.agents/skills/new-async-task/references/CODE-EXAMPLES.md @@ -1,154 +1,277 @@ -# Wavelet 异步任务代码示例 (Cordis 插件化架构) +# Wavelet 异步任务代码示例 -这些示例用于在 Cordis 插件中开发或修改 Asynq 任务时快速套用。 +这些示例用于新增或修改 Wavelet Asynq 任务时快速套用。复制前先对照当前代码,因为任务框架可能随项目演进。 ---- +## 任务元数据与常量定义 -## 1. 任务定义与 Handler 编写 +在对应的业务包 `internal/apps//tasks.go` 中定义 Asynq task type、Admin task type 和 `TaskMeta`。 -在对应的业务插件中(如 `backend/plugins/domain/user/tasks.go`): +```go +package upload + +import ( + "OpenFlare/internal/infra/task" +) + +// 异步任务类型标识。格式建议为 "{module}:{action}"。 +const CleanupUnusedUploadsTask = "upload:cleanup_unused" + +// 管理员可下发的任务类型标识。用于 Admin API 的 task_type。 +const TaskTypeCleanupUploads = "cleanup_unused_uploads" + +// CleanupUnusedUploadsMeta 任务元数据 +var CleanupUnusedUploadsMeta = task.TaskMeta{ + Type: TaskTypeCleanupUploads, + AsynqTask: CleanupUnusedUploadsTask, + Name: "清理未使用上传", + Description: "清理超过1小时未使用的上传文件", + SupportsTime: false, + MaxRetry: task.DefaultMaxRetry, + Queue: task.QueueDefault, + Retryable: true, +} +``` + +带参数任务把前端表单元数据放在 `Params`。`Name` 必须和 payload JSON tag 对齐。 + +```go +{ + Type: TaskTypeSendEmail, + AsynqTask: SendEmailTask, + Name: "发送邮件", + Description: "异步发送系统邮件", + SupportsTime: false, + MaxRetry: defaultMaxRetry, + Queue: QueueDefault, + Retryable: true, + Params: []TaskParam{ + { + Name: "to", + Label: "接收邮箱 (To)", + Type: "string", + Required: true, + Placeholder: "receiver@example.com", + Description: "接收邮件的目标邮箱地址", + }, + { + Name: "subject", + Label: "邮件主题 (Subject)", + Type: "string", + Required: true, + Placeholder: "请输入邮件主题", + Description: "发送邮件的主题标题", + }, + { + Name: "body", + Label: "邮件内容 (Body)", + Type: "text", + Required: true, + Placeholder: "请输入邮件内容", + Description: "发送邮件的内容主体", + }, + }, +} +``` + +## 无参数 Handler + +放在对应业务模块,例如 `internal/apps/upload/tasks.go`。 + +```go +package upload + +import ( + "context" + + "OpenFlare/internal/infra/task" +) + +type CleanupUnusedUploadsHandler struct{} + +func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { + task.AppendLog(ctx, "开始扫描未使用上传") + + // 调用 model/service 完成业务逻辑。 + // 批量处理时按批次记录日志,不要每条记录都 AppendLog。 + + msg := "清理完成" + task.AppendLog(ctx, "%s", msg) + return &task.TaskResult{Message: msg}, nil +} +``` + +## 带参数 Handler + +实现 `PayloadValidator` 做 Admin 下发时的服务端校验和标准化。`Execute` 仍然解析 payload,因为 Scheduler 和 Retry 不一定经过 Admin 校验路径。 ```go package user import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" + "context" + "encoding/json" + "errors" + "fmt" + "strings" - "github.com/hibiken/asynq" + "OpenFlare/internal/infra/task" ) -// 异步任务类型标识。格式推荐为 "{plugin}:{action}" -const TaskTypeSendEmail = "user:send_email" - type SendEmailPayload struct { - To string `json:"to"` - Subject string `json:"subject"` - Body string `json:"body"` + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` } -// Handler 处理函数 -func (p *Plugin) handleSendEmail(ctx context.Context, t *asynq.Task) error { - var req SendEmailPayload - if err := json.Unmarshal(t.Payload(), &req); err != nil { - return fmt.Errorf("解析任务参数: %w", err) - } +type SendEmailHandler struct{} - req.To = strings.TrimSpace(req.To) - req.Subject = strings.TrimSpace(req.Subject) - req.Body = strings.TrimSpace(req.Body) - if req.To == "" || req.Subject == "" || req.Body == "" { - return errors.New("to、subject、body 不能为空") - } +func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) { + if len(payload) == 0 { + return nil, errors.New("任务参数不能为空") + } - // 执行实际邮件发送业务逻辑 - return p.emailSvc.Send(ctx, req.To, req.Subject, req.Body) + var req SendEmailPayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, fmt.Errorf("无效的 JSON 格式: %w", err) + } + + req.To = strings.TrimSpace(req.To) + req.Subject = strings.TrimSpace(req.Subject) + req.Body = strings.TrimSpace(req.Body) + if req.To == "" || req.Subject == "" || req.Body == "" { + return nil, errors.New("to、subject、body 不能为空") + } + + return json.Marshal(req) +} + +func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { + var req SendEmailPayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, fmt.Errorf("解析任务参数: %w", err) + } + + task.AppendLog(ctx, "开始发送邮件到: %s", req.To) + + // 调用业务服务发送邮件。 + + msg := fmt.Sprintf("邮件成功发送至: %s", req.To) + task.AppendLog(ctx, "%s", msg) + return &task.TaskResult{Message: msg}, nil } ``` ---- +## 统一注册 -## 2. 插件内自包含注册 (`Apply`) - -在插件的 `Apply(ctx *core.Context)` 中: +在 `internal/infra/task/handlers/register.go` 注册。Admin dispatch 的 `ValidateAndNormalizePayload` 和 Worker 执行都依赖这里。 ```go -package user +package handlers import ( - "time" - - "github.com/Rain-kl/Wavelet/core" - "github.com/Rain-kl/Wavelet/core/extpoints" + "OpenFlare/internal/apps/upload" + "OpenFlare/internal/apps/user" + "OpenFlare/internal/infra/task" ) -func (p *Plugin) Apply(ctx *core.Context) error { - // 1. 注册 Asynq 异步任务消费处理器 - ctx.Task().Register( - TaskTypeSendEmail, - p.handleSendEmail, - extpoints.WithTaskRetry(3), - extpoints.WithTaskTimeout(2*time.Minute), - ) - - // 2. 注册 Cron 调度任务(例如每天凌晨 3 点清理过期 Token) - ctx.Schedule().RegisterCron( - "0 3 * * *", - "user:cleanup_expired_tokens", - map[string]any{"scope": "expired"}, - ) - - return nil +func Register() { + task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{}) + task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{}) } ``` ---- +## Cron 调度和配置 -## 3. 业务中投递异步任务 +系统默认的定时任务必须通过 Goose SQL 迁移初始化插入到 `schedules` 表。 + +在 `internal/infra/persistence/migrator/goose/postgres` 下的示例: + +```sql +-- +goose Up +INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) +VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; + +-- +goose Down +-- 根据业务需求决定是否需要在此删除 +``` + +对于 `sqlite` 也可以使用类似的 `INSERT INTO ... ON CONFLICT(id) DO NOTHING` 语法。数据库更新后,后端会自动热重载调度器。 + +## Handler 测试 + +带参数任务至少覆盖合法 payload、空 payload、非法 JSON、缺失必填和标准化。 ```go -func (s *UserService) TriggerWelcomeEmail(ctx context.Context, toEmail, username string) error { - payload, _ := json.Marshal(SendEmailPayload{ - To: toEmail, - Subject: "欢迎加入", - Body: fmt.Sprintf("你好 %s,欢迎使用我们的平台!", username), - }) +func TestSendEmailHandlerValidatePayload(t *testing.T) { + tests := []struct { + name string + payload []byte + want SendEmailPayload + wantErr bool + }{ + { + name: "valid payload is normalized", + payload: []byte(`{"to":" user@example.com ","subject":" hi ","body":" body "}`), + want: SendEmailPayload{ + To: "user@example.com", + Subject: "hi", + Body: "body", + }, + }, + { + name: "empty payload", + payload: nil, + wantErr: true, + }, + { + name: "invalid json", + payload: []byte(`{`), + wantErr: true, + }, + { + name: "missing required field", + payload: []byte(`{"to":"user@example.com","subject":"","body":"body"}`), + wantErr: true, + }, + } - task := asynq.NewTask( - TaskTypeSendEmail, - payload, - asynq.MaxRetry(3), - ) + h := &SendEmailHandler{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPayload, err := h.ValidatePayload(tt.payload) + if gotErr := err != nil; gotErr != tt.wantErr { + t.Fatalf("ValidatePayload(%s) error = %v, want error presence = %t", tt.payload, err, tt.wantErr) + } + if tt.wantErr { + return + } - _, err := s.taskClient.EnqueueContext(ctx, task) - return err + var got SendEmailPayload + if err := json.Unmarshal(gotPayload, &got); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", gotPayload, err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("ValidatePayload(%s) mismatch (-want +got):\n%s", tt.payload, diff) + } + }) + } } ``` ---- +`Execute` 测试优先验证业务服务调用、错误返回和结果摘要;日志可只验证关键路径,避免把精确日志文本写成脆弱断言。 -## 4. 任务处理函数单元测试 +## Admin Dispatch 测试形状 + +Admin dispatch 测试关注通用链路是否调用了 `PayloadValidator`,不要为每种任务在 handler 里写 if 分支。 ```go -func TestSendEmailPayloadValidation(t *testing.T) { - tests := []struct { - name string - payload []byte - wantErr bool - }{ - { - name: "valid payload", - payload: []byte(`{"to":"user@example.com","subject":"hi","body":"welcome"}`), - wantErr: false, - }, - { - name: "empty payload", - payload: nil, - wantErr: true, - }, - { - name: "missing required fields", - payload: []byte(`{"to":"user@example.com","subject":""}`), - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var req SendEmailPayload - err := json.Unmarshal(tt.payload, &req) - if err == nil { - if strings.TrimSpace(req.To) == "" || strings.TrimSpace(req.Subject) == "" || strings.TrimSpace(req.Body) == "" { - err = errors.New("missing fields") - } - } - if (err != nil) != tt.wantErr { - t.Fatalf("validation error = %v, wantErr = %v", err, tt.wantErr) - } - }) - } +func TestDispatchTaskValidatesPayload(t *testing.T) { + // 1. 初始化测试 DB 和 task.AsynqClient。 + // 2. 注册测试 handler: task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{}) + // 3. POST /api/v1/admin/tasks/dispatch,传入非法 payload。 + // 4. 断言响应为 400,错误信息清晰,且没有创建可执行任务。 } ``` + +需要 Redis/Asynq 时优先复用项目现有测试模式;没有现成依赖时可用 `miniredis` 初始化 `task.AsynqClient`。不要把 `internal/infra/task` 依赖塞进通用 testhelper 造成 import cycle。 diff --git a/.agents/skills/new-setting/SKILL.md b/.agents/skills/new-setting/SKILL.md index 332718de..12a88416 100644 --- a/.agents/skills/new-setting/SKILL.md +++ b/.agents/skills/new-setting/SKILL.md @@ -1,122 +1,161 @@ --- name: "new-setting" -description: "Wavelet 项目专用:当新增或修改基于 Cordis 插件的静态配置文件绑定、动态系统/业务设置声明 (ctx.Settings)、管理台热加载设置或前端公共配置消费逻辑时必须使用。" +description: "Wavelet 项目专用:当新增或修改启动时设置、数据库系统设置、业务设置、公共可见配置、/admin/system 参数配置、/admin/settings 图形化设置界面,或前端公共配置消费逻辑时必须使用。本技能指导设置类型判定、SystemConfig 字段与 visibility、goose SQL 初始化/升级、热更新读取、公共配置暴露、shadcn 图形组件和验证流程。" --- -# 插件配置与动态系统设置开发规范 (Cordis 插件化架构) +# 新增设置项 -本技能覆盖 Wavelet 在 Cordis 架构下的静态与动态设置体系。 +本技能覆盖 Wavelet 的设置体系。开始前先读仓库根目录 `AGENTS.md`,遵守项目级规则:HTTP 路由只在 `internal/router/router.go` 注册、API 变更后运行 `make swagger`、提交前运行 `make code-check`、不要删除 `frontend/node_modules`、`internal/util/` 不引入框架依赖。 ---- +如果需要在 `/admin/settings` 增加或调整图形化设置组件,同时阅读 [shadcn](../shadcn/SKILL.md)。如果只是新增 Go 读取逻辑、测试或错误处理,再按需阅读对应 `go-*` skill。 -## 1. 两种配置模式与选型 +## 先判定设置类型 -Wavelet 提供两种维度的配置能力: +Wavelet 当前有两套设置入口: -| 模式 | 机制 | 适用场景 | 注册/读取方式 | -| :--- | :--- | :--- | :--- | -| **静态启动配置** | `config.yaml` / 环境变量 | 进程启动前必须确定、不热更新的配置(如第三方 API Key、端口、物理路径) | `ctx.Config().Bind("plugins.", &cfg)` | -| **动态系统设置** | 数据库持久化 + 缓存 + 热加载 | 运行时可被管理员在管理控制台动态修改的业务规则、开关、阈值 | `ctx.Settings().Register(SettingSchema{...})` | +- 启动时设置:来自 `config.yaml` 或环境变量,适合进程启动前必须确定、通常不热更新的基础配置。 +- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 实体(key 常量在 model)与 `repository` 读取层(含 Redis hash 缓存)访问,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。 ---- +系统设置分三种使用语义: -## 2. 插件内配置声明与绑定 +- 业务设置:`type=business`,由管理员配置,影响业务规则,例如用户额度、业务限制。 +- 系统设置:`type=system`,由管理员配置,影响平台能力、基础开关、外部服务参数。 +- 公共可见配置:附加在业务设置或系统设置之上,由 `visibility=1` 控制是否通过公开接口返回给前端使用。它不是第三种数据库 `type`,不要把 `type` 写成 `public`。 -### 2.1 静态配置声明与绑定 (`DeclareConfig` 与 `ctx.Config().Bind`) +业务设置和系统设置互斥:一个配置项只能选择 `business` 或 `system`。是否公开给前端由 `visibility` 决定:`0` 表示隐藏,`1` 表示 `/api/v1/config/public` 可见。 -静态启动配置遵循插件自包含声明与解耦规范: +特殊设置组件不一定需要新增 `SystemConfig` 参数项。例如认证源设置、模板管理这类有独立模型和 API 的功能,应沿用对应领域模型,不要为了出现在 `/admin/settings` 强行创建参数配置。 -```go -type OrderStaticConfig struct { - PaymentGatewayURL string `config:"payment_gateway_url" env:"ORDER_PAYMENT_URL" default:"https://pay.example.com"` - TimeoutSeconds int `config:"timeout_seconds" env:"ORDER_TIMEOUT" default:"30"` - ApiKey string `config:"api_key" env:"ORDER_API_KEY" secret:"true"` -} +## 先定位真实链路 -// 可选:实现 DeclareConfig 声明配置模式(若需门禁求值则实现 core.ConfigGatedPlugin) -func (p *Plugin) DeclareConfig() []core.ConfigBinding { - return []core.ConfigBinding{ - {Prefix: "plugins.order", Target: &OrderStaticConfig{}}, - } -} +修改前快速查看这些文件,确认当前实现没有漂移: -func (p *Plugin) Apply(ctx *core.Context) error { - var cfg OrderStaticConfig - // 从统一配置源绑定 plugins.order 节点配置(支持 YAML 与环境变量覆盖) - _ = ctx.Config().Bind("plugins.order", &cfg) - return nil -} -``` +- `internal/model/system_configs.go`: 配置 key 常量(`ConfigKey*`)、`SystemConfig` 实体与字段语义;**不含**持久化读取 API。 +- `internal/repository/system_config.go`: 配置读取与缓存(`GetSystemConfigByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey`、`ListVisibleSystemConfigs` 等)。 +- `internal/infra/persistence/migrator/goose/postgres/*.sql` 和 `internal/infra/persistence/migrator/goose/sqlite/*.sql`: `system_configs` 表结构、初始化 seed、后续升级迁移。 +- `internal/infra/persistence/migrator/migrator.go`: goose 迁移入口和 PostgreSQL/SQLite 方言选择。 +- `internal/testhelper/test_helper.go`: Go 测试用默认系统配置 seed。 +- `internal/apps/admin/system_config/routers.go`: `/api/v1/admin/system-configs` 参数表 API。 +- `internal/apps/config/routers.go`: `/api/v1/config/public` 公共配置响应。 +- `frontend/components/common/admin/system.tsx`: `/admin/system` 参数表管理界面,展示所有参数配置项。 +- `frontend/components/common/settings/system-settings.tsx`: `/admin/settings` 图形化设置页入口。 +- `frontend/components/common/settings/*-tab.tsx`: `/admin/settings` 各图形化设置分组。 +- `frontend/lib/services/admin/*`: Admin 系统配置 service 类型和 API 封装。 +- `frontend/lib/services/config/*`、`frontend/hooks/use-public-config`、`frontend/components/layout/*`: 前端公共配置消费链路。 -### 2.2 动态设置注册 (`ctx.Settings().Register`) +## 新增数据库系统设置 -插件在 `Apply` 中声明其支持动态调节的 Schema: +按影响面选择步骤,不要只改 UI 或只改默认值。 -```go -func (p *Plugin) Apply(ctx *core.Context) error { - // 1. 注册内部业务规则设置 - ctx.Settings().Register(extpoints.SettingSchema{ - Key: "order.auto_cancel_mins", - Default: 15, - Description: "未支付订单自动取消时间 (分钟)", - Category: "business", - Public: false, - }) +1. 定义配置 key。 + - 在 `internal/model/system_configs.go` 添加 `ConfigKey...` 常量。 + - key 使用 lowercase snake case,例如 `search_engine_indexing_enabled`。 + - 值仍存为字符串;布尔值用 `"true"` / `"false"`,数值用十进制字符串,复杂结构用 JSON 字符串。 - // 2. 注册前端公共可见开关 (Public: true) - ctx.Settings().Register(extpoints.SettingSchema{ - Key: "order.invoice_enabled", - Default: true, - Description: "是否开启订单电子发票开具功能", - Category: "business", - Public: true, // 允许前端通过 /api/v1/config/public 匿名读取 - }) +2. 初始化默认配置。 + - 如果修改初始 schema,必须同步 `internal/infra/persistence/migrator/goose/postgres/` 和 `internal/infra/persistence/migrator/goose/sqlite/` 中的 goose SQL。 + - 既有库新增配置时,新增一组时间戳递增的双 SQL 迁移文件,分别放在 PostgreSQL 和 SQLite 目录;不要回到 GORM AutoMigrate 或 Go 代码 seed。 + - 新库初始化也需要包含同一个默认 key:当前初始 seed 在 `202606090001_initial_schema.sql` 的 `INSERT INTO system_configs (...) VALUES ... ON CONFLICT (key) DO NOTHING`。 + - 设置正确的 `Type`:只能是 `"system"` 或 `"business"`。 + - 设置正确的 `Visibility`:公共可见填 `1`,内部配置填 `0`。 + - 默认值要和 Go 读取侧的零值或兜底值一致,避免首次启动和数据库缺失时行为不同。 + - 如果相关 Go 包测试依赖默认配置,同步 `internal/testhelper/test_helper.go` 的 `seedDefaultConfigs` 和公共 key 列表。 - return nil -} -``` +3. 读取配置。 + - 后端业务代码通过 `internal/repository` 读取:`repository.GetBoolByKey`、`repository.GetIntByKey`、`repository.GetDecimalByKey` 或 `repository.GetSystemConfigByKey`;key 常量仍用 `model.ConfigKey*`。 + - 禁止新增或调用 `model.Get*ByKey` / `model.ListVisibleSystemConfigs` 等数据访问 API(model 无 CRUD)。 + - 运行时可热更新的规则不要放进 `config.Config`;启动时设置才走 `internal/infra/config/model.go` 和 `config.example.yaml`。 + - 不要在 handler 或业务代码里直接读 `os.Getenv()`。 ---- +4. 如果前端需要未登录或全局消费,暴露为公共可见配置。 + - 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `repository.ListVisibleSystemConfigs` 返回所有可见 key/value。 + - `/api/v1/config/public` 的 `data` 是动态对象:后端返回 `map[string]string`,前端类型是 `Record`。 + - 前端读取时按配置 key 访问,必要时在消费侧把字符串转换为 boolean/number/JSON。 + - 检查使用方的 query key,更新后需要 invalidate `["public-config"]`。 + - 只有公共配置 API 形状或注释变化时才需要更新 Swagger;单纯新增 `visibility=1` 的 key 通常不需要改 `PublicConfigResponse` 类型。 -## 3. Schema 核心属性说明 +5. 如果管理员需要图形化配置,更新 `/admin/settings`。 + - 先阅读 shadcn skill。 + - 根据设置语义选择现有 tab:安全类进 `security-tab.tsx`,运营类进 `operation-tab.tsx`,系统基础参数进 `system-tab.tsx`,其它菜单或杂项进 `other-tab.tsx`。 + - `SystemSettingsMain` 当前通过 `AdminService.listSystemConfigs("system")` 只加载 `type=system` 的配置;`type=business` 的配置若也需要图形化入口,先确认是否要调整查询范围或放到其它 Admin 页面。 + - 新的图形组件优先放在 `frontend/components/common/settings/`,使用现有 `AdminService.updateSystemConfig`。 + - 更新成功后 invalidate `["admin", "system-configs"]`;公共可见配置还要 invalidate `["public-config"]`。 + - 使用 Sonner toast 反馈成功或失败。 + - 不使用 `any`,不要硬编码页面级 `max-w-*`,页面根容器保持 `w-full`。 -- **`Key`**:全局唯一配置键名,推荐小写点分蛇形命名(如 `domain.setting_name`)。 -- **`Default`**:默认值(支持 `bool`、`int`、`string`、`JSON 结构`)。 -- **`Category`**: - - `"business"`:业务规则、用户额度、流程开关。 - - `"system"`:系统底层调优、平台安全参数。 -- **`Public`**:布尔值。若为 `true`,会自动暴露至 `/api/v1/config/public`,供前端未登录或全局消费。 -- **`ReadOnly`**:若为 `true`,管理台仅做展示,禁止通过 API 修改。 +6. `/admin/system` 参数表通常不需要新代码。 + - 只要 `SystemConfig` 默认数据存在,参数表会展示配置项。 + - `/admin/system` 偏向所有参数配置项的键值管理,不替代 `/admin/settings` 的友好图形界面。 ---- +## 新增启动时设置 -## 4. 前端消费与管理台界面 +只有在配置必须随进程启动确定、不能或不应热更新时,才走启动时设置。 -### 4.1 前端公共配置消费 -当设置声明为 `Public: true` 时,前端可使用 `usePublicConfig` hook 消费: +1. 在 `internal/infra/config/model.go` 添加配置字段。 +2. 在 `config.example.yaml` 添加示例值和说明。 +3. 确认 Viper 现有加载逻辑能绑定该字段;需要环境变量时沿用当前命名和绑定方式。 +4. 运行时代码从 `config.Config.
.` 读取。 +5. 不要把启动时设置同步塞进 `SystemConfig`,除非产品明确需要运行时覆盖。 -```tsx -import { usePublicConfig } from "@/hooks/use-public-config"; +## 常见模式 -export function InvoiceButton() { - const { data: config } = usePublicConfig(); - const invoiceEnabled = config?.["order.invoice_enabled"] === "true"; +### 布尔公共设置 - if (!invoiceEnabled) return null; - return ; -} -``` +- model key:`ConfigKeyFeatureEnabled = "feature_enabled"`(定义在 `internal/model`) +- goose SQL 默认值:`value='false'`,`type` 按语义选 `"system"` 或 `"business"`,`visibility=1`。 +- 后端读取:`repository.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。 +- 公共响应:`/api/v1/config/public` 的 `data.feature_enabled` 为字符串 `"true"` 或 `"false"`。 +- 前端图形控件:`Switch`,保存时写 `"true"` / `"false"`。 -### 4.2 管理后台热加载设置 (`/admin/settings` 与 `/admin/system`) -- **`/admin/system`**:通用参数表,自动根据所有已注册的 `SettingSchema` 渲染全量配置项的读写管理。 -- **`/admin/settings`**:图形化设置面板。如需在特定的 Tab 中提供高体验的开关/输入组件,参考 `shadcn` 技能使用标准组件进行开发,并通过 `AdminService.updateSystemConfig` 更新。 +### 数值业务设置 ---- +- model key:`ConfigKeyMaxSomething = "max_something"`。 +- goose SQL 默认值:例如 `"5"`,`type` 通常为 `"business"`,只有前端公共消费时才设 `visibility=1`。 +- 后端读取:`repository.GetIntByKey` 或 `repository.GetDecimalByKey`。 +- 前端图形控件:`Input type="number"` 或合适的 shadcn 数值控件;保存前做最小必要校验,错误用 toast。 -## 5. 质量与验证门禁 +### JSON 设置 + +- 默认值使用合法 JSON,例如 `"{}"` 或 `"[]"`。 +- 在 repository 或业务 logics 中提供解析函数,像 `repository.GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误;不要在 model 中做 IO。 +- 前端不要直接拼接 JSON 字符串;用 `JSON.stringify` 写入,用类型化对象在组件中操作。 + +## 验证 + +根据改动范围运行最小有效验证,最后提交前必须运行项目门禁。 + +- 新增或修改系统配置默认值、visibility 或公共配置读取:至少运行相关 Go 包测试,例如: ```bash -make format -make code-check -go test ./plugins/... +go test ./internal/repository ./internal/apps/config ./internal/apps/admin/system_config ``` + +- 新增 goose 迁移后,至少用当前数据库方言跑一次迁移;如果 SQL 同时改了 PostgreSQL 和 SQLite,尽量覆盖两种方言。涉及 schema/seed 的任务还应遵循 database-migration skill。 + +- 公共配置 API 注释或 handler 签名改动后: + +```bash +make swagger +``` + +- 前端图形设置改动后: + +```bash +cd frontend && pnpm typecheck && pnpm lint +``` + +- 提交前: + +```bash +make code-check +``` + +如涉及前端页面体验,启动本地服务并用浏览器验证 `/admin/settings` 和 `/admin/system`:配置能显示、保存、toast 反馈正常、刷新后值保持、公共配置消费方能即时或刷新后生效。 + +## 相关 Skills + +- shadcn:新增或调整 `/admin/settings` 图形化设置组件时使用。 +- database-migration:新增或修改 `system_configs` schema、默认 seed 或 goose SQL 迁移时使用。 +- go-error-handling:配置解析、缺失配置、非法值错误需要跨包返回时使用。 +- go-testing:为配置读取、公共配置 API 或 Admin 配置 API 添加测试时使用。 +- go-context:配置读取在请求链路或后台链路中传递取消和超时时使用。 diff --git a/.agents/skills/push-notification/SKILL.md b/.agents/skills/push-notification/SKILL.md index 5c42e9ee..d7dc7006 100644 --- a/.agents/skills/push-notification/SKILL.md +++ b/.agents/skills/push-notification/SKILL.md @@ -5,7 +5,7 @@ description: "Wavelet 项目专用:当需要开发或接入新的系统通知 # 新增消息推送与通知事件开发规范 -本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](../../../AGENTS.md),遵守项目级核心规则。 +本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。 --- @@ -15,9 +15,9 @@ Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异 | 目录/包名 | 职责定位 | 包含内容与设计细节 | | :--- | :--- | :--- | -| **`backend/plugins/domain/message_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 | -| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:
1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。
2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。
3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 | -| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 | +| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 | +| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:
1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。
2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。
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()` 副作用。 | | **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 | | **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 | | **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。
`w_push_histories` 存放消息发送记录用于审计。 | @@ -38,8 +38,8 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/pkg/listener" + "OpenFlare/internal/apps/admin/push" + "OpenFlare/internal/listener" ) var NewUserRegistered = push.EventMetadata{ @@ -68,8 +68,8 @@ func handleUserRegistered(ctx context.Context, event listener.UserRegistered) { > `EventTrigger.Trigger` 已内置异步 Goroutine 与 `context.WithoutCancel`;处理函数内直接调用即可,无需外层 `go func()`。 ### 步骤 2:在 `listener/` 定义域事件并在 `register.go` 装配 -1. 在 `internal/listener/` 新增域事件类型、`Emit*` 与 `On*` 注册函数(参考 `internal/listener/admin_login.go`)。 -2. 在 `register.go` 中注册元数据并订阅域事件: +1. 在 `internal/listener/` 新增域事件类型、`Emit*` 与 `On*` 注册函数(参考 [admin_login.go](file:///Users/ryan/DEV/Go/Wavelet/internal/listener/admin_login.go))。 +2. 在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 中注册元数据并订阅域事件: ```go func Register() { @@ -84,7 +84,7 @@ func Register() { 在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件: ```go -import "github.com/Rain-kl/Wavelet/pkg/listener" +import "OpenFlare/internal/listener" func Register(c *gin.Context) { // ... 注册成功逻辑 ... @@ -104,7 +104,7 @@ func Register(c *gin.Context) { `Init` 中的 `SyncEvents` 会将 `user_registered` 元数据同步到 `w_push_events`,管理员即可在前端配置推送渠道。 ### 步骤 5:编写集成测试 -在 `custom_events/` 或 `listener/` 包内添加测试,验证 `Emit*` → handler → `DefaultTrigger.Trigger` 全链路。测试 setup 须显式调用 `custom_events.Register()`(或 `bootstrap.RegisterPushDomainEvents()`)和 `push.SyncEvents`,参考 `admin_login_test.go`。 +在 `custom_events/` 或 `listener/` 包内添加测试,验证 `Emit*` → handler → `DefaultTrigger.Trigger` 全链路。测试 setup 须显式调用 `custom_events.Register()`(或 `bootstrap.RegisterPushDomainEvents()`)和 `push.SyncEvents`,参考 [admin_login_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/admin_login_test.go)。 --- diff --git a/.agents/skills/release-guide/SKILL.md b/.agents/skills/release-guide/SKILL.md index 8d937ba5..c8ca7972 100644 --- a/.agents/skills/release-guide/SKILL.md +++ b/.agents/skills/release-guide/SKILL.md @@ -1,13 +1,13 @@ --- name: "release-guide" -description: "项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。" +description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。" --- # Release Commit Message Guide ## 目标 -当用户准备发布新版本时,本 Skill 负责: +当用户准备发布 Wavelet 新版本时,本 Skill 负责: 1. 根据上一正式版本 Tag 以来的提交,整理面向用户的发版说明; 2. 新建 **独立的** `chore(release): vX.Y.Z` 提交(可附带将 `docs/changelog` 从 `[unreleased]` 落版)。 @@ -51,8 +51,8 @@ description: "项目专用:根据自上一个正式版本 Tag 以来的提交 「修复/优化」与「新增」的判定(关键): - **判定标准是“该功能在上一正式版本中是否已存在”**: - - 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」; - - 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。 + - 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」; + - 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。 - 禁止把新功能的开发期修复/优化写进「修复」或「优化」:新功能此前版本没有,谈不上“修复/优化了旧行为”。 示例: diff --git a/.auto/checks.sh b/.auto/checks.sh index 1dcafbe6..28e86a36 100755 --- a/.auto/checks.sh +++ b/.auto/checks.sh @@ -1,57 +1,53 @@ #!/bin/bash -# Autoresearch GUARD — hard veto. Every line here protects an invariant that is -# unrelated to the primary metric, plus the anti-cheat red lines. -set -uo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "${ROOT}/backend" +# Correctness gate: must pass after every edit. Fails fast on real breakage. +set -euo pipefail +cd "$(dirname "$0")/.." -# Same per-checkout lint cache as measure.sh: golangci-lint's default cache is -# machine-wide, so a different worktree analysing identical sources can make this -# Guard read back a stale verdict. Key it to the backend directory. -GOLANGCI_LINT_CACHE="${TMPDIR:-/tmp}/ar-lint-cache-$(pwd | cksum | awk '{print $1}')" -export GOLANGCI_LINT_CACHE +echo "==> go vet ./..." +go vet ./... 2>&1 | tail -20 -STATUS=0 -fail() { echo "GUARD FAIL: $1"; STATUS=1; } +echo "==> go build ./..." +go build ./... 2>&1 | tail -20 -source "${ROOT}/.auto/baseline.env" +echo "==> golangci-lint run (repo config)" +golangci-lint run 2>&1 | tail -20 -# --- 1. Correctness ----------------------------------------------------------- -go build ./... || fail "go build failed" -go vet ./... || fail "go vet failed" +# 全量单测(sqlite + miniredis,纯本地无需外部服务;2026-08-16 起全绿) +echo "==> go test ./internal/... ./pkg/..." +go test ./internal/... ./pkg/... 2>&1 | grep -E "^--- FAIL|^FAIL" | head -20 || true +if go test ./internal/... ./pkg/... > /tmp/auto_gotest.log 2>&1; then + : +else + tail -30 /tmp/auto_gotest.log + exit 1 +fi -go test ./... > /tmp/ar_guard_test.txt 2>&1 || true -FAILS=$(grep -cE '^(FAIL|--- FAIL)' /tmp/ar_guard_test.txt || true) -[ "${FAILS}" = "0" ] || { grep -E '^(FAIL|--- FAIL)' /tmp/ar_guard_test.txt | head -20; fail "tests failing (${FAILS})"; } +# 前端测试(vitest;2026-08-16 起全绿) +echo "==> pnpm exec vitest run (frontend)" +(cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm exec vitest run --reporter=dot > /tmp/auto_vitest.log 2>&1) || { + tail -30 /tmp/auto_vitest.log + exit 1 +} -# --- 2. Cordis architecture gate --------------------------------------------- -"${ROOT}/scripts/check_cordis_architecture.sh" > /dev/null 2>&1 || fail "cordis architecture check failed" +# SPDX license 头门禁(repo 自带约定) +echo "==> make license-check" +make license-check 2>&1 | grep "needs license" | head -10 || true +if make license-check > /tmp/auto_license.log 2>&1; then + : +else + tail -15 /tmp/auto_license.log + exit 1 +fi -# --- 3. Project lint gate must stay clean ------------------------------------ -PROJECT_LINT=$(golangci-lint run 2>&1 | grep -cE '\.go:[0-9]+:[0-9]+: ' || true) -[ "${PROJECT_LINT}" = "0" ] || { fail "project golangci-lint reports ${PROJECT_LINT} issues"; } +# 并发密集包 -race 门禁(2026-08-16 全仓 -race 清零后纳入,防回归; +# frpc/frps 慢套件不含在此,另做全量周期验证) +echo "==> go test -race (concurrency packages)" +RACE_PKGS="./internal/apps/oauth/ ./internal/apps/openflare/tls/ ./internal/apps/openflare/uptimekuma/ ./internal/apps/upload/cache/ ./internal/repository/ ./pkg/cache/disk/ ./pkg/logger/ ./internal/infra/persistence/batchwriter/" +if go test -race -count=1 $RACE_PKGS > /tmp/auto_race.log 2>&1; then + : +else + grep -E "WARNING: DATA RACE|^--- FAIL|^FAIL" /tmp/auto_race.log | head -20 + exit 1 +fi -# --- 4. Anti-cheat: the yardstick itself is immutable ------------------------ -REF_SHA_NOW=$(shasum -a 256 "${ROOT}/.auto/lint.ref.yaml" | awk '{print $1}') -[ "${REF_SHA_NOW}" = "${REF_SHA}" ] || fail "pinned yardstick .auto/lint.ref.yaml was modified" - -# --- 5. Anti-cheat: the project gate may only ever be STRENGTHENED ------------ -WEAK=$(python3 "${ROOT}/.auto/check_gate_weaken.py" 2>&1) || { echo "${WEAK}"; fail "project gate weakened"; } - -# --- 6. Anti-cheat: no new suppressions -------------------------------------- -NOLINT=$(rg '//\s*nolint' --glob '*.go' 2>/dev/null | wc -l | tr -d ' ') -[ "${NOLINT}" -le "${BASE_NOLINT}" ] || fail "nolint directives grew (${NOLINT} > ${BASE_NOLINT})" - -# --- 7. Anti-cheat: no tests deleted, no packages lost ----------------------- -TEST_FUNCS=$(rg -c '^(func Test|func Benchmark)' --glob '*_test.go' 2>/dev/null | awk -F: '{s+=$2} END {print s+0}') -[ "${TEST_FUNCS}" -ge "${BASE_TEST_FUNCS}" ] || fail "test funcs shrank (${TEST_FUNCS} < ${BASE_TEST_FUNCS})" -TEST_FILES=$(rg --files --glob '*_test.go' 2>/dev/null | wc -l | tr -d ' ') -[ "${TEST_FILES}" -ge "${BASE_TEST_FILES}" ] || fail "test files deleted (${TEST_FILES} < ${BASE_TEST_FILES})" -PASSED=$(grep -c '^ok' /tmp/ar_guard_test.txt || true) -[ "${PASSED}" -ge "${BASE_TESTS_PASSED}" ] || fail "passing packages shrank (${PASSED} < ${BASE_TESTS_PASSED})" - -# --- 8. License headers on Go sources (CI gate) ------------------------------ -"${ROOT}/scripts/update_go_license.sh" --check > /dev/null 2>&1 || fail "license header check failed" - -if [ "${STATUS}" = "0" ]; then echo "CHECKS OK"; fi -exit ${STATUS} +echo "OK: checks passed" \ No newline at end of file diff --git a/.auto/ideas.md b/.auto/ideas.md index 192abce5..c14bc920 100644 --- a/.auto/ideas.md +++ b/.auto/ideas.md @@ -1,11 +1,169 @@ -# Ideas Backlog -- extpoints 四处同构 Register 代码用泛型 helper 收敛(migration/setting/schedule/task) -- message_gateway admin_handlers/push_handlers/push_channels 三文件互为重复 → 提取共享构建函数 -- message_gateway/repository.go 222-240 ↔ 332-350 重复查询块 -- driver_asynq_worker/plugin.go 371-390 ↔ 420-439 重复 -- admin/repository.go:507 nilerr 真 bug → 读代码确认 + 回归测试 -- inproc 驱动 contextcheck 两处 → 传 ctx -- gosec 权限修复(test_helpers.go 0755→0750/0600, postgres.go 49) -- nestif 四处拆函数 -- 全库性能巡查:N+1 查询、循环内 compile/alloc、锁粒度、LIKE 无 EscapeLike、缺失索引 -- 包结构优化(upload/shared、message_gateway 文件命名混乱) +# Ideas backlog (代码质量) + +## 已尝试并收尾(2026-08-16 会话,14 个实验,108→8) + +- 生产代码 golangci 扩展集 13 类 linter 全量清理(modernize/perfsprint/ + errorlint/canonicalheader/usestdlibvars/intrange/wastedassign/errname/ + forcetypeassert/prealloc/gosec/recvcheck/exhaustive),剩余 8 处全部为 + 有据可查的刻意保留项(telegram %v、3 处嵌套 struct omitempty、 + 3 处 not-found 惯例、1 处 encoding/json 接收者混合)。 +- 测试代码质量维度(testifylint/usetesting/thelper)25→0。 +- 前端 eslint/tsc 0。 +- 修复中积累的工具经验:golangci-lint v2 `--fix` 的 import 管理不可靠, + 跑完必须 `goimports -w`;`--max-issues-per-linter=0` 才能拿到全量清单 + (默认 50 + max-same-issues=3 会掩盖重复模式);cyclop 与 exhaustive + 有张力(显式 case 计入复杂度)。 + +## 未来可深化方向(均经评估) + +- 测试可运行性修复:`go test ./internal/...` 目前在 main 上就有失败 + (无本地 redis、frpc 进程测试 flaky)。修复这些环境问题后,可以把 + `go test` 加入 checks.sh,解锁 paralleltest/tparallel 维度 + (t.Parallel 提速 + 正确性,目前因共享状态+不可运行而放弃)。 +- frontend biome 格式漂移(76 文件):一次性 `make format` 提交, + 与质量修复分开做,不进基准。 +- fieldalignment:结构体内存布局优化,但会改变 JSON key 顺序且有 + 位置字面量风险 —— 若做,需按文件人工核对,不进自动基准。 +- Go 1.26 新特性扫描:`go vet` 新分析器、golangci-lint 新 linter + (如 recvcheck 之后的 new receivers 检查)随版本跟进。 +- 文档/示例代码(docs/、scripts/)质量:目前不在 golangci 范围(tests:false + 之外还有 scripts 目录),可用同一扩展集扫 scripts/ 下的 main.go。 + +## 会话收尾(2026-08-16,run #23 后) + +- 已确认收敛:基准 5 维全下限、-race 全仓清零、双端测试全绿、发布构建可复现、 + config.example.yaml ↔ model.go 同步无漂移、无 flaky 测试。 +- 明确评估为不值得做的方向:paralleltest/tparallel(共享全局状态风险)、 + fieldalignment(JSON key 顺序变化)、biome 格式漂移(纯噪声)、 + frpc/frps 慢测试注入 backoff(为省 ~40s 改生产时序逻辑,不值)。 +- 未来如继续:可周期跑 `go test -race ./...` 全量(frpc/frps 慢套件); + 或前端 a11y 用 axe 做浏览器级审计(超出 eslint 静态规则)。 + +## 本会话新增(runs #39-#43) + +已修复: +- agent auth_cache negative 缓存无上限 → 10k 上限+过期清理(DoS 防护) +- relay/flared 与 agent 三份重复 authenticateAccessToken → 共享 agent 版(负缓存共享,DB 压力下降) +- websocket 三 hub:runWritePump 抽取、wsClientCore 嵌入(close/enqueue 单份)、broadcastAgent 合并 +- frps/frpc TOML 注入 → pkg/protocol/toml.go TOMLQuote 转义全部插值 + +评估后不修/暂缓: +- cloudflare listMemberItems、config_version snapshot 证书循环的 N+1:管理端小 N 低频, + 加批量 repo API 属投机优化;若未来组员数量变大再做 ListZoneDomainsByIDs。 +- fatcontext ×3(oauth/upload/auth_source cache listener):别名赋值误报,非嵌套包装。 +- objectstore newOSSBackend/newWebDAVBackend 恒 nil error:跨后端工厂签名统一,刻意设计。 +- edge/updater assetNameForGOOSGOARCH 恒 "linux":跨平台预留参数,刻意泛化。 +- agent ResolverDirective explicitResolvers 原样插入 nginx conf:管理员配置属可信输入; + 若未来开放给低权限角色需加格式校验(IP 解析)。 +- pkg/render/openresty 管理端旋钮(ClientMaxBodySize 等)原样插值:管理员权限范围内。 +- frontend/settings/profile.tsx(858 行)超 AGENTS.md ~600 行指引:存量组件,拆分属 + 纯重构无质量增益,暂缓;若后续要改该页面功能时顺手拆 components/。 + +## Run #44(全仓 -race 扫描) + +- 发现并修复 upload/cache 监听器 DATA RACE:goroutine 读可变全局 db.Redis vs + testhelper 清理置 nil。根因修复=启动时捕获 redisClient(oauth×2/repository×2 + 同型监听器一并加固),StopUploadMetaCacheListener 补 done 等待。 +- 教训:testhelper 不能 import upload/cache(循环依赖);"捕获替代全局读"是 + 无环的根因修法。 +- 全仓 -race 现为 0 竞争(internal/... + pkg/...);建议周期性重跑。 + +## LIKE 转义(本轮已修日志搜索 4 站点;同类遗留) + +- 已修:analytics/node_access_log_filter.go、analytics/access_log_filter.go、 + logstore/postgres_store.go×2(PG/SQLite 加 ESCAPE '\',CH 用默认反斜杠转义)。 + 新助手 pkg/util/like.go EscapeLike + 单测。 +- Run #47 已收尾全部 GORM 站点:upload.go keyword、user.go:73/76/188/229 + (含 OAuth uniqueUsername base 转义——外部输入含 _ 曾误报用户名冲突)、 + task_execution.go task_type 前缀。均加显式 ESCAPE '\'。 +- 刻意保留:upload.go:199 `image/%`(系统常量)、config_version.go:65(系统生成)。 + +## Run #48(后台 goroutine panic 防护,55db1c01) + +- 全仓 20 处裸 go func() 零 recover → 新增 pkg/util/goroutine.go `Go(fn)`(recover + + slog + debug.Stack,runtime.Caller 自动记录调用点无需手写名字),22 个站点全部收口 + (oauth/upload/system_config/auth_source 的嵌套 ctx-done watcher 也含)。 +- 教训:脚本括号深度匹配首轮会跳过嵌套内层 goroutine,需跑两轮;新 Go 文件必须先跑 + scripts/update_go_license.sh(license-check 会拦)。 +- 已过期记录:go test ./internal/... ./pkg/... 现全过(94 ok)——"main 上测试失败" + 不再成立。scripts/、docs/ 下 Go 文件用扩展 linter 扫过:0 issues。 + +## Run #50(发现型 linter 扫描,全证伪——勿重跑这些维度) + +- errchkjson 12 处:全部为不可能失败的 json.Marshal(纯 string/int/[]string + 结构体;admin/logs/routers.go:131 与 waf/ip_group_sync.go:255 的 "unsafe type" + 是传递性保守标记,RawMessage/time.Time 内容来自必然成功的 marshal)。 +- spancheck 1 处(pkg/trace/trace.go:61):误报,helper 正常返回 span, + 唯一调用方 internal/infra/task/executor.go:242 有 defer span.End()。 +- unparam ×2(objectstore oss/webdav 恒 nil error):已在 #43 前评估为跨后端工厂签名统一。 +- 性能排查:正则全部包级编译(无函数内 MustCompile);包级 map 全为有界静态注册表; + task AppendLog 走 DB 非内存累积;push escapeJSONString 用法正确。 +- 结论:Go 静态可发现的低垂果实已穷尽。剩余方向:frontend axe a11y 浏览器级审计、 + 周期性 -race 重跑(上次 #49 干净)、运维类增长审查。 + +## Run #54(认证页 axe a11y 审计+修复,451ce525) + +已修(复扫验证生效): +- 布局级全局:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 18 节点/页清零)、 + header Kbd 对比度 text-foreground/70、空态/错误/加载 h3→p(heading-order 清零)。 +- 页面级:dashboard 4 个 Progress aria-label、users 分页 prev/next aria-label、 + admin/system 无内容 Tabs→aria-pressed 按钮组(aria-valid-attr-value critical 清零)。 +- / 与 /admin/system 现 axe 0 违规。 + +后续可做(页面级批量,工作量大): +- admin 数据表格行内操作图标按钮(编辑/删除)与 Switch 开关无 aria-label —— 每张管理表逐个补; +- muted 文本对比度(card description、radix tabs trigger、primary 按钮文字)—— shadcn 默认色在浅色主题下 axe 判 fail,改主题变量影响面大需设计确认。 +- 审计环境复用:后端 :3100 + CONFIG_PATH=/tmp/of-audit/config.yaml(sqlite)、docker redis --network host、 + pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100;admin 密码 reset-passwd 重置。注意 :3000 是生产实例勿动。 + +## Run #54-#55(认证页 a11y 审计,两轮 keep) + +已修复(浏览器 axe 复扫验证): +- 全局布局:sidebar 折叠按钮 aria-label、Sidebar role=navigation、header Kbd 对比度、 + dashboard Progress aria-label、分页 prev/next、空态/加载 h3→p、admin/system Tabs→aria-pressed。 +- 主题级根因:--primary indigo-500(#6366f1) 白字对比度仅 4.27(AA 需 4.5) → indigo-600 + oklch(51.1% 0.262 276.966) ≈6.8,一处修复全站 contrast 清零。 +- 控件名:access-analytics 刷新、events-tab Switch/编辑/删除、openflare-ops Switch/Select/ + Input(htmlFor)/Textarea、table-browser/sql-console SelectTrigger;heading-order:眉题 + h4→p(cache-manager/user-detail-sheet)、卡片题 h3→p(task-manager/file-manager)。 +- 结果:dashboard、admin/system、admin/settings、admin/logs、admin/push、admin/tasks、 + admin/database、files 共 8 页 axe 0 违规。 + +审计方法(可复用):后端 :3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite, +api_prefix 必须显式 /api)+ docker redis --network host(本机 bridge NAT 坏)+ +pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100 + admin 密码经 reset-passwd 重置。 +axe 注入:eval 建 CDN script → Promise 轮询 window.axe → axe.run。 +教训:表单页异步渲染,须 wait≥5s 再扫否则漏报 label 规则;Radix SelectValue +value='' 时 placeholder 不显示,combobox 无名需 aria-label 兜底。 + +## 剩余可做 + +- 抽查其余页面(websites/[zoneId]、origins/detail、responses 编辑器等富交互页) + ——contrast 已由主题修复覆盖,预期只剩个别控件名。 +- 周期性 go test -race ./... 全量重跑(上次干净为 run #49 后)。 + +## Run #56(富交互页抽查,keep,63e3b852) + +- 扫描 11 页:websites/origins/proxy-routes/certificates/dns-accounts 直接 0 违规 + (indigo-600 主题修复已覆盖全站 contrast)。 +- 修复 3 处并复扫归零: + 1. cloudflare/components/sync-tasks-panel.tsx 状态筛选 SelectTrigger 加 aria-label + (Radix SelectValue value='' 时 placeholder 不渲染,combobox 无名)。 + 2. components/common/settings/access-token.tsx 安全提示 text-amber-600→amber-700 + (12px 小字对比度不足)。 + 3. settings/notifications 面包屑页缺 h1 → sr-only h1。教训:h1 不能作为 + BreadcrumbList 子元素(axe list 规则报 list 语义破坏),须放 外; + BreadcrumbPage 无 asChild 支持。 +- a11y 维度至此穷尽:累计 14 页 axe 全部 0 违规。 + +## Run #59(-shuffle=on 测试顺序随机化扫描,keep,b56f2763) + +- 新维度:`go test -shuffle=on` 抓到 config_version 包测试顺序依赖—— + TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 + Custom 用例留在进程级 RAM 配置缓存的值(GetSystemConfigByGroup 未命中时 + ram.Set 回填,TTL 跨测试存活;:memory: DB + SetDB 换库不使缓存失效)。 +- 修复:setupOriginErrorPageSnapshotDB / setupConfigVersionTestDB 换 DB 前后 + 接入既有 ram.ResetForTest()。包内 shuffle×8 + 全仓 shuffle 复扫全过。 +- 教训:默认源码顺序掩盖顺序依赖;-shuffle=on 是低成本周期扫描手段。 + 全仓 -race(#58 后)同样干净。其余用 SetDB 的测试包如后续 shuffle 复发, + 同法接入 ResetForTest 即可。 diff --git a/.auto/log.jsonl b/.auto/log.jsonl index 276d010a..98cd317c 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -1,5 +1,60 @@ -{"ts":"2026-08-28T00:00:00Z","iter":0,"type":"baseline","metrics":{"lint_issues":45,"dup_issues":15,"tests_passed":44},"description":"baseline: 45 golangci issues (15 dupl), all tests pass","asi":{"note":"quick wins queue: gofumpt(4)+revive(8); then goconst/mnd; gosec; nilerr bug; contextcheck; dupl batches; nestif"}} -{"ts":"2026-08-28","iter":1,"type":"keep","metrics":{"lint_issues":34,"dup_issues":15,"tests_passed":44},"delta":-11,"description":"goconst(9): taskCategoryUpload/taskQueueDefault consts in upload/task; reuse logDBNameSQLite in admin; mnd(1): defaultCleanupInterval in disk cache; staticcheck SA9004: split typed const group in asynq executor","asi":{"lesson":"golangci v2 defaults cap reporting at 50/3 - uncapped via issues:max-issues-per-linter/max-same-issues=0 (strict-only change); formatter war resolved: make format now = golangci-lint fmt (same gate as code-check), 203-file gofumpt normalization committed as infra"}} -{"ts":"2026-08-28","iter":2,"type":"keep","metrics":{"lint_issues":33,"dup_issues":15,"tests_passed":44},"delta":-1,"description":"nilerr real bug: FlushTaskExecutionLog swallowed cache faults (non-miss errors) and silently dropped buffered task logs; now propagates wrapped error, ErrCacheMiss stays a no-op. 3 regression tests (fault / miss / persist+clear) with miniredis + stubDBService(in-memory sqlite)","asi":{"lesson":"FlushTaskExecutionLog callers in executor.go only log errors, so returning wrapped err is safe; tests need SetDBService injection since testhelper.SetupTestEnvironment targets infra/database global not admin dbService"}} -{"ts":"2026-08-28","iter":3,"type":"keep","metrics":{"lint_issues":26,"dup_issues":15,"tests_passed":44},"delta":-7,"description":"revive cleanup: symmetric accessors ctx->_, unused params->_, doc comments for SetDBServiceForTest and StorageDriver const block","asi":{"lesson":"callers pass targetCfg positionally so _ at def site is safe; accessor ctx removal considered but _ keeps 24 call sites stable"}} -{"ts":"2026-08-28","iter":4,"type":"keep","metrics":{"lint_issues":24,"dup_issues":15,"tests_passed":44},"delta":-2,"description":"contextcheck: thread app-lifetime ctx through inproc drivers. inproc_cron: Plugin.Start(ctx)->scheduler.Start(ctx)->registerJob(ctx,def); cron closures now Dispatch/log/invoke with ctx (child WithTimeout). inproc_worker: InprocQueue.baseCtx captured at Start(ctx); executeTask uses WithTimeout(q.baseCtx). app.go passes signal/base ctx (only cancelled on shutdown) so this adds graceful-shutdown propagation","asi":{"lesson":"Start ctx is lifetime-scoped (signal.NotifyContext or GoContext), NOT startup-scoped - safe for task dispatch; nil-guard baseCtx in queue.Start allows tests constructing queue directly"}} +{"type":"config","name":"前后端代码质量优化(符合最佳实践)","metricName":"total_issues","metricUnit":"","bestDirection":"lower"} +{"run":1,"commit":"305d609","metric":108,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":2,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":107,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":36},"status":"checks_failed","description":"基线:总问题 108(golangci 107 + eslint 1)。checks 失败的唯一原因:repo 自带 golangci gate 有 2 个既有 gosec G115 问题(预期内,首次修复后即绿)。","timestamp":1786871594292,"segment":0,"confidence":null,"asi":{"hypothesis":"baseline","next_action_hint":"修复 internal/apps/edge/observability/linux.go 的 2 个 G115 gosec 问题后 checks.sh 才能通过;之后每次迭代即可正常 keep/discard"}} +{"run":2,"commit":"f1f6bb8","metric":106,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":105,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"修复 internal/apps/edge/observability/linux.go 的 2 个 gosec G115 整数溢出转换:helper 改为接收 int64 b,用 gosec 认可的饱和乘法模式(uint64 域乘积 + 上界比较),去掉原 //nolint:gosec,语义不变(Bsize 恒为正)。repo 自带 gate 首次全绿。","timestamp":1786872064145,"segment":0,"confidence":null,"asi":{"hypothesis":"修复 gosec G115:multiplyUint64ToInt64 改为 accept int64 b 并采用 gosec 认可的饱和乘法模式","insight":"gosec G115 不接受分支上界证明(a > MaxInt64/b),但接受先算 uint64 乘积再 if v > MaxInt64 饱和的模式,无需 nolint","next_action_hint":"下一步批量清理 modernize(37)/perfsprint(18) 等自动可修复类别,用 golangci-lint --fix 后人工核对 diff"}} +{"run":3,"commit":"b76f707","metric":74,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"modernize 37→5(-32):interface{}→any、内置 max/min、slices/maps 辅助、strings.Cut/SplitSeq、strings.Builder(修复 mail.go O(n²) 拼接)。逐 hunk 核对语义等价;omitzero 冲突修复被自动跳过(wire 格式不变);手动清 4 处遗留 sort import + 2 处 QF1012。","timestamp":1786872502383,"segment":0,"confidence":17,"asi":{"hypothesis":"批量应用 modernize 自动修复(interface{}→any、max/min、slices.Sort/Contains、strings.Cut/SplitSeq、maps.Copy、strings.Builder)","insight":"golangci-lint --fix 会把 omitempty→omitzero 的冲突修复跳过(2个文件保留原 tag,wire 格式不变,好);但 fixer 会遗留未使用的 sort import,需手动清 4 处;mail.go 的 Builder 迁移附带暴露 2 个 QF1012,顺手用 fmt.Fprintf 修复。全部修复语义等价,已逐项核对 diff","next_action_hint":"剩余 modernize=5 应为 omitzero 冲突文件;下轮先处理 perfsprint(18):先看 --fix 是否安全再决定"}} +{"run":4,"commit":"699e95f","metric":56,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"perfsprint 18→0:strconv.Itoa/FormatInt/FormatUint/FormatBool 替代 fmt.Sprintf、无动词 fmt.Errorf→errors.New、纯字符串拼接。全部语义等价(已核对 diff)。修正 fixer 遗留的 import 问题(引入 goimports 统一整理)。","timestamp":1786872884713,"segment":0,"confidence":3.0588235294117645,"asi":{"hypothesis":"perfsprint --fix:%d→strconv.Itoa/FormatInt、%t→FormatBool、%s+const→拼接、无动词 Errorf→errors.New","insight":"重要:golangci-lint v2 fixer 的 import 管理不可靠(删除/添加 import 会出错,53 个文件中 5 处报 undefined)+ 遗留未用 import。已安装 goimports(repo make format 本来就需要它),对改动文件统一 goimports -w 修复。后续只要用 --fix 就要记得跑 goimports -w","next_action_hint":"剩余大头:errorlint(12)、canonicalheader(8)(usestdlibvars 同类)、recvcheck(7)、wastedassign(7)。errorlint 需手工逐处判断;先做 canonicalheader+usestdlibvars(自动可修复但要核对)"}} +{"run":5,"commit":"d0414b4","metric":45,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。","timestamp":1786873098921,"segment":0,"confidence":2.1724137931034484,"asi":{"hypothesis":"canonicalheader+usestdlibvars --fix:Header key 统一规范大小写、GET/OPTIONS 等方法常量","insight":"GitHub header 修正前后的 wire 格式完全一致(Go 在 Set 时本来就会规范化),纯代码层面修正,零行为风险;下次遇到同类 100% 安全","next_action_hint":"剩余:errorlint(12) 需逐处人工判断(其中 3 处 err != context.Canceled、2 处 %v wrap、若干 ==/类型断言);recvcheck(7) 是模型接收者一致性;wastedassign(7) 删 TODO 赋值;intrange(3)/modernize(5)/nilnil(3)/prealloc(3)/forcetypeassert(3)/errname(1)/eslint(1)"}} +{"run":6,"commit":"ce28f63","metric":38,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"wastedassign 7→0:删除 7 处死初始化(snapshot.go 三连、push 三件套 content、format.go numStr),改 var 声明,零行为变化。","timestamp":1786873485497,"segment":0,"confidence":2.978723404255319,"asi":{"hypothesis":"wastedassign 7→0:删除 7 处死初始化(x := \"\" 后所有分支都赋值)改为 var 声明","insight":"replace 工具会归一化 replacement_text 的前导空白;对需要缩进的编辑直接用 sed/gofmt -w 处理更稳","next_action_hint":"剩余:errorlint(12)、recvcheck(7)、modernize(5)、intrange(3)、nilnil(3)、prealloc(3)、forcetypeassert(3)、errname(1)、eslint(1)"}} +{"run":7,"commit":"288b74d","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"intrange 3→0 + modernize 5→3:for i:=0;i landmark 包裹(region 规则,真实页面由 AuthShell 提供)。scripts/ Go 代码用扩展 linter 集扫描为 0 问题(死路,未纳入基准)。metric 持平 8","next_action_hint":"a11y 覆盖已达:登录/注册页/OTP/CapWidget 全零违规。剩余页面(设置页、admin)需较重 mock。会话可收尾,或周期跑全量 -race 验证无 flake"}} +{"run":27,"commit":"6c128e0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到最复杂认证路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单),mock getCapToken 避免 jsdom 无 Worker 环境限制。零违规。vitest 120→121 全绿。","timestamp":1786885536618,"segment":0,"confidence":null,"asi":{"hypothesis":"axe 审计覆盖最复杂的认证渲染路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单)","insight":"新增第 5 个 axe 测试(vitest 120→121):mock @/lib/cap-solver 的 getCapToken 直接 resolve token(避免 jsdom 无 Web Worker),config 开 cap_login_enabled/cap_auto_solve,注册页渲染出 CAPTCHA 已通过态 + 表单全字段 → 零违规。vi.mock('@/lib/cap-solver') 对其它测试无害(仅 capEnabled 时渲染 CapWidget)。metric 持平 8","next_action_hint":"axe 覆盖已达 5 个认证表单态(登录/注册/OTP/验证小部件手动/注册+验证)。剩余:设置页与 admin 页需较重 mock。可收尾,或周期跑全量 -race 验证无 flake"}} +{"run":28,"commit":"40eee77","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":0},"status":"keep","description":"前端显式 any 类型清理 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable 注释删除。tsc/eslint/vitest 121 全绿。","timestamp":1786886086713,"segment":0,"confidence":null,"asi":{"hypothesis":"前端显式 any 类型清理:全仓 grep 仅 2 处 any —— Slot children?: any 与 useControlledState 的 Rest extends any[],均为真实类型缺陷","insight":"全前端 any 计数 2→0。slot.tsx:children?: any → React.ReactNode | MotionValue | MotionValue(motion HTMLMotionProps 的真实 children 类型);顺带修复潜在崩溃 —— 原代码在 isValidElement 检查前就访问 children.type,children 缺失时 TypeError,改为 isValidChild/childrenType 先计算(hooks 无条件,rules-of-hooks 合规),无效 children 返回 null。use-controlled-state.tsx:Rest extends any[] → unknown[]。两处 eslint-disable no-explicit-any 注释随之删除(无抑制注释)。tsc/eslint/vitest 121/checks.sh 全绿。benchmark 无关(metric 持平 8)。注意:run #28 的 run_experiment 被用户中断(aborted),但代码修复已通过全部门禁验证","next_action_hint":"用户要求合并到 main 并推送"}} +{"run":29,"commit":"511bed8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":63,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":124},"status":"keep","description":"修复 2 个新增 unconvert 问题(linux.go 中 int64(stat.Bsize) 恒等转换,Statfs_t.Bsize 在 Linux 上本就是 int64),删除多余转换零行为变化;total 10→8 回到 5 维全下限。","timestamp":1786894372432,"segment":0,"confidence":null,"asi":{"category":"unconvert","hypothesis":"会话恢复后 measure 显示 total=10,出现 2 个新的 unconvert 问题(internal/apps/edge/observability/linux.go:261-262 的 int64(stat.Bsize) 恒等转换,Linux Statfs_t.Bsize 本就是 int64)。删除多余转换,零行为变化","finding":"unconvert 是 repo 自带配置启用的 linter,此前 baseline 无此问题,最近用户提交/Go 版本变化后新增;修复后 5 维回到全下限 8","next_action_hint":"会话恢复点确认:total=8(5 维全下限,8 项均为有据可查的刻意保留)。下一轮候选:静态检查新维度(staticcheck SA 系列在 repo 配置中已启用且为 0)、或把 docs/ 下 vitepress 站点的构建纳入 measure 防回归(docs build 不属质量计数,不进基准)"}} +{"run":30,"commit":"d49c7e1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":183,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"checks_failed","description":"Agent 发现 Token 比较改为 SHA-256 后恒定时间 Compare,堵住未授权节点注册口的计时侧信道。checks 在 -race 阶段超时(包本身已单独跑绿)。","timestamp":1787667218065,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","rollback_reason":"checks.sh 在 go test -race 阶段 300s 超时(包单独跑全绿,预算不够)","next_action_hint":"同一修复用 checks_timeout_seconds=600 重跑"}} +{"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}} +{"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}} +{"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}} +{"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}} +{"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}} +{"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}} +{"run":37,"commit":"0290c93","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。","timestamp":1787670327304,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /oauth/login 与 /oauth/{source}/authorize 每次请求都往 Redis 写 10 分钟 state,无上限","finding":"按 sessionHash 计数,10 分钟内最多 20 个;超出返回业务错误。mock Redis 补 Incr/Expire。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 洪水"}} +{"run":38,"commit":"c0a82f8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":85,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开密码登录口按 IP 限制 10 分钟内最多 20 次失败,堵住未授权爆破。metric 持平 8。","timestamp":1787670665553,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /user/login 失败无 IP 限流,未授权方可无限爆破","finding":"按 ClientIP 计数,10 分钟 20 次失败后拒绝;成功清零。管理员已登录不在范围内。","next_action_hint":"下一轮可查公开 CAP challenge 洪水或边缘节点 access_token 明文比较"}} +{"run":39,"commit":"be5d067","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":76,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"auth_cache negative 缓存加上限防 DoS + relay/flared 删除重复 authenticateAccessToken 改用 agent 共享缓存版","timestamp":1787708052241,"segment":0,"confidence":null,"asi":{"hypothesis":"negative cache 无上限可被伪造 token 撑爆内存;relay/flared 与 agent 三份重复的 authenticateAccessToken","next_action_hint":"继续扫其他无界缓存/限流缺口","result":"metric 持平 8(8 个均为 deliberate keeper),安全修复不计入 metric","security":"negative cache 加 10k 上限+过期清理;relay/flared 复用 agent.AuthenticateAccessToken(共享 2min 正/10min 负缓存,DB 压力下降)"}} +{"run":40,"commit":"0dd2cf9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 hub 去重:抽 runWritePump 共享写泵 + 合并 agent 广播函数为 broadcastAgent","timestamp":1787708370650,"segment":0,"confidence":null,"asi":{"hypothesis":"三份 hub 的 writePump 完全重复(仅日志前缀不同),readPump 已有 runReadPump 抽取先例;BroadcastWAFIPGroups/BroadcastActiveConfig 复制粘贴","next_action_hint":"close() 3 份小重复可再合并但收益低;继续找其他模块的重复/无界增长","result":"metric 持平 8,全测试绿","refactor":"新增 websocket/write_pump.go runWritePump(对齐 runReadPump 模式),agent/relay/flared writePump 改委托;agent_hub 抽 broadcastAgent 合并两个广播函数"}} +{"run":41,"commit":"efd8268","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)","timestamp":1787708975609,"segment":0,"confidence":null,"asi":{"hypothesis":"agentClient/relayClient/flaredClient 字段与 close/enqueue 完全相同,用组合(嵌入 wsClientCore)消除三份重复","next_action_hint":"代码库经 40 轮已高度收敛;后续可周期性跑 go test -race 全量","result":"metric 持平 8,全测试绿;净减 ~60 行重复代码","refactor":"新增 websocket/client_core.go:wsClientCore(nodeID/conn/send/done/once) + 共享 close/enqueue;三个 client 结构体改为嵌入"}} +{"run":42,"commit":"ed1efd3","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"补 wsClientCore 并发测试 + close() 防 nil conn 守卫","timestamp":1787709222794,"segment":0,"confidence":null,"asi":{"hypothesis":"wsClientCore 并发语义(close 幂等、enqueue 不阻塞/关后拒绝)无测试覆盖","next_action_hint":"websocket 包已有基础并发测试;继续其他模块扫描","result":"metric 持平 8;测试还暴露 close 未防 nil conn 的防御缺口,已补守卫","refactor":"新增 websocket/client_core_test.go 3 个 -race 测试;client_core.go close() 增加 nil conn 守卫"}} +{"run":43,"commit":"4f8e7e6","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用","timestamp":1787709693698,"segment":0,"confidence":null,"asi":{"hypothesis":"frps/frpc TOML 配置用裸 Fprintf 拼接,token/password/域名含引号、反斜杠、换行时会破坏配置或注入键","next_action_hint":"检查其他配置生成点是否有同类注入面(nginx/openresty 配置)","result":"metric 回到 8;frpc 慢套件 16.8s 全绿;mnd 曾短暂+1(Grow 魔法数),删除微优化后消除","security":"新增 pkg/protocol/toml.go TOMLQuote 转义助手 + toml_test.go;relay/frps renderConfig 与 flared/frpc buildFrpcToml 全部插值改为转义输出"}} +{"run":44,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固","timestamp":1787711092906,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 go test -race 可能暴露并发 bug(此前仅局部验证)","next_action_hint":"继续扫其他模块;可考虑把 -race 纳入周期性检查","result":"发现并修复 1 个真实 DATA RACE;修复后全仓 -race 0 竞争,metric 持平 8","root_cause":"upload/cache 监听器 goroutine 读可变全局 db.Redis,与 testhelper 清理置 nil 竞争;testhelper 导入 upload/cache 有循环依赖,故用启动时捕获客户端的根因修复(oauth/repository 同型监听器一并加固),并补 StopUploadMetaCacheListener 同步等待 done"}} +{"run":45,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"探索轮:索引对齐/前端请求瀑布/BasicAuth 注入面三假设均证伪,无代码变更","timestamp":1787711474404,"segment":0,"confidence":null,"asi":{"hypothesis":"SQLite 迁移缺 PG 同款索引;前端存在串行请求瀑布;nginx BasicAuth 密码有注入面","next_action_hint":"代码库已高度收敛;下轮可考虑 observability 查询构造器审计或周期性重跑 -race","rollback_reason":"纯探索无代码变更,无需回滚","result":"三个假设均无产出:①索引对比(修正提取正则后)PG/SQLite 完全对齐,SQLite 仅多 legacy w_* 冗余索引;②前端 await Service 均在事件处理器非渲染期;③BasicAuth 密码经 base64 编码(字母表无元字符)无注入面","lessons":"grep 提取 SQL 时注意 IF NOT EXISTS 变体,否则产生假缺口"}} +{"run":46,"commit":"2cb3392","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"LIKE 过滤器转义修复:日志搜索含 %/_ 的输入不再被当通配符;pkg/util 新增 EscapeLike 共享助手 + 单测","timestamp":1787712116152,"segment":0,"confidence":null,"asi":{"hypothesis":"日志搜索 LIKE 过滤器不转义 %/_/\\,含下划线的路径/主机名搜索结果错误","next_action_hint":"同类遗留站点(upload/user/task_execution GORM 搜索)已记 ideas.md,可作后续轮次","result":"修复 4 个站点:analytics 两处 CH 过滤器 + logstore postgres_store 两处(PG/SQLite 加 ESCAPE '\\')。新增 pkg/util/like.go EscapeLike + 单测。metric 持平 8,全部测试通过","scope_decision":"GORM 实体搜索站(upload keyword、user username/email)同 bug 类但低风险且可能依赖现有通配语义,本轮不动"}} +{"run":47,"commit":"3528323","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复","timestamp":1787712555794,"segment":0,"confidence":null,"asi":{"hypothesis":"GORM 实体搜索站与 #46 日志搜索同 bug 类:LIKE 模式不转义通配符","next_action_hint":"LIKE 类已全部收尾;下轮可考虑 ideas.md 的测试可运行性方向或周期性全仓 -race 重跑","result":"6 站点修复(upload keyword、user username/email 前缀+contains、OAuth uniqueUsername base、task_type 前缀),PG/SQLite 加显式 ESCAPE。系统常量模式刻意保留(upload.go:199 image/%)。metric 持平 8,测试全绿","scope_decision":"uniqueUsername 的 base 来自 OAuth 用户信息属外部输入,含 _ 会误报用户名冲突——虽是系统生成后缀模式也需转义 base 本身"}} +{"run":48,"commit":"55db1c0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口","timestamp":1787713583118,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 20 处后台 goroutine 裸跑零 recover,任一 panic 击穿 gin handler 级恢复直接崩溃进程","next_action_hint":"goroutine 收口完成;下轮可周期性 go test -race ./... 全量重跑(上次 #44)","result":"pkg/util.Go(fn) 共享助手(runtime.Caller 自动记录调用点 + slog + debug.Stack),22 个站点全部收口(含嵌套 watcher)。脚本转换两轮(首轮漏嵌套内层)。首次 checks_failed 因新文件缺 SPDX 头,update_go_license.sh 修复后全绿。metric 持平 8"}} +{"run":49,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frpc restartProcess 发布未初始化 exec.Cmd 的数据竞争:proc.Cmd/Status 改为 Start 成功后加锁发布","timestamp":1787714358791,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 go test -race ./... 重跑(上次 #44 后又改了 repository/logstore/goroutine 站点)能抓出新数据竞争","next_action_hint":"-race 全仓清零;下轮候选:frontend axe a11y 审计,或 Go 1.26 新 linter 扫描","result":"全仓 -race 抓到 1 个真实 race:frpc/manager.go restartProcess 在 cmd.Start() 前就发布 proc.Cmd+Status=running(Start 中 cmd.Process 未赋值),测试读句柄与之竞争。修复=Start 成功后再加锁发布(manager.go:219-220 移入 err==nil 分支)。frpc 包 -race 连续 3 次通过。其余全仓 -race 干净"}} +{"run":50,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"扩展 linter 发现扫描 + 热路径性能排查:errchkjson/unparam/spancheck 等 9 个新维度,全部核实为不可失败/刻意设计/误报","timestamp":1787714798689,"segment":0,"confidence":null,"asi":{"hypothesis":"基准外发现型 linter(errchkjson/unparam/spancheck/exptostd/durationcheck/makezero/reassign/asasalint/bidichk)+ 热路径性能 grep 能找到真实缺陷","next_action_hint":"发现型 linter 已穷尽;下轮候选:frontend axe a11y 浏览器级审计,或任务执行日志/DB 增长类运维审查","result":"全部证伪:errchkjson 12 处均核实为不可能失败的 marshal(纯 string/int/[]string 结构体;2 处 unsafe 标记是传递性保守);spancheck 1 处误报(唯一调用方 executor.go:242 有 defer span.End());unparam×2 为已评估的工厂签名设计;正则全在包级编译无热路径重编译;包级 map 全为有界静态注册表;AppendLog 走 DB 无内存累积。escapeJSONString 用法正确。无代码变更"}} +{"run":51,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"运行时资源审计:HTTP 客户端超时覆盖 + 查询热路径索引覆盖,两项全部干净无缺陷","timestamp":1787715135724,"segment":0,"confidence":null,"asi":{"hypothesis":"运行时资源审计:出站 HTTP 客户端超时覆盖 + LIKE/精确匹配热路径的 DB 索引支撑","next_action_hint":"两项审计干净。剩余:frontend axe a11y(需起前端+浏览器)、周期性 -race 重跑、uploads LOWER(file_name) contains 若成为性能痛点需改前缀语义+表达式索引","result":"全部干净:15 个 http.Client 中 14 个显式 Timeout,唯一无 Timeout 的 agent/nginx checkStubStatus 走 NewRequestWithContext+WithTimeout 边界;users.username 全部精确匹配热路径由 UNIQUE 内联索引覆盖(PG+SQLite 均确认),email/task_type/logstore 过滤列均已有索引;uploads LOWER(file_name) contains 不可用 b-tree 但属管理端低频,改语义才有收益故不动"}} +{"run":52,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":71,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"SQL 注入面 + Go 运行时陷阱模式 + react-hooks 依赖三重审计,全部干净无缺陷","timestamp":1787715503278,"segment":0,"confidence":null,"asi":{"hypothesis":"原始 SQL 拼接注入面 + 经典 Go 运行时陷阱(time.After 循环泄漏/defer-in-loop/context.Background 丢失取消)+ 前端 react-hooks 依赖正确性","next_action_hint":"静态+运行时审计维度已穷尽。剩余唯一大项:frontend axe a11y 浏览器级审计(需起前端 dev server + agent_browser)","result":"全部干净:db_manage SQL 控制台为管理端允许例外且表名双引号转义正确、analytics Sprintf 均内部常量表名+参数化占位符;time.After 仅 3 处且均为 select 单次等待/有界重试;defer 均在函数级非循环内;19 处 context.Background() 全部为后台监听器(WithCancel)/重启路径/自带超时的清理任务,无请求 ctx 丢弃;react-hooks/exhaustive-deps 全仓零违规(CLI 临时规则,未改配置)"}} +{"run":53,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"前端 axe a11y 浏览器审计:唯一违规为无后端环境产物,无代码缺陷","timestamp":1787716027952,"segment":0,"confidence":null,"asi":{"hypothesis":"前端 axe-core 浏览器级 a11y 审计(最后一个未探索大维度)","next_action_hint":"a11y 维度已探索但受登录墙限制:完整审计需起后端+种子账号登录。若未来重跑:起 Go 后端 + admin 登录后逐页 axe.run","result":"agent-browser 0.34.0 已装好可复用。axe 审计覆盖所有无认证可达页面(/login、/register、/docs/* 全被登录墙拦截):唯一违规 page-has-heading-one 是环境产物——后端未启动时页面卡在 session-check/publicConfig-pending 态只渲染 Spinner,真实表单的 AuthHeading h1 未渲染;瞬态态用 h3 属可接受的瞬态层级。无代码缺陷。已认证页面需后端才能审计"}} +{"run":54,"commit":"451ce52","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":93,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"认证页 axe a11y 审计+修复:7 处布局级真实违规全修,复扫验证 dashboard/admin/system 归零;基准 total_issues 保持 8 不变(纯质量收益)","timestamp":1787718397798,"segment":0,"confidence":null,"asi":{"hypothesis":"认证页 axe a11y 审计(起后端+登录突破登录墙):修复布局级真实违规","next_action_hint":"已验证 / 与 /admin/system 归零。剩余页面级:admin 表格行内操作按钮/Switch 无 aria-label、muted 文本对比度——需逐表补标签,工作量大已归档 ideas.md","result":"修复 7 处全局问题并复扫验证:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 违规 18 节点/页清零)、header Kbd 对比度 text-foreground/70(每页 1 处)、dashboard 4 个 Progress aria-label、分页按钮 aria-label、空态/错误/加载 h3→p(heading-order 清零)、admin/system 无内容 Tabs 改 aria-pressed 按钮组(aria-valid-attr-value critical 清零)。dashboard 与 admin/system 现 0 违规","setup":"审计环境:后端 go run . api @:3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite+redis host 网络 docker)、前端 pnpm dev --port 3002(WAVELET_BACKEND_URL=:3100)、admin 密码经 reset-passwd 重置"}} +{"run":55,"commit":"e66dea9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"a11y 收尾:主题级对比度根因修复(indigo-500→600)+12 处控件 accessible name+4 处 heading-order,7 页复扫全 0 违规;基准 total_issues 保持 8","timestamp":1787719908229,"segment":0,"confidence":null,"asi":{"hypothesis":"页面级 a11y 批量收尾:主题级 color-contrast 根因 + 表格/表单控件 accessible name","next_action_hint":"7 页复扫全 0 违规。剩余:其余页面(websites/origins/cloudflare 等仅扫过 contrast 已由主题修复覆盖)可抽查;-race 周期重跑","result":"根因1:--primary indigo-500(#6366f1) 对 #fafafa 仅 4.27 → 改 indigo-600 oklch(51.1% 0.262 276.966)(~6.8 AA),全站 contrast 清零(一处主题修复覆盖所有页面)。修复 12 处控件名:access-analytics 刷新按钮、events-tab Switch/edit/delete、openflare-ops ToggleRow Switch+geoip/kuma Select+FieldInput Input htmlFor+discovery Textarea、table-browser/sql-console SelectTrigger;heading-order:cache-manager/user-detail-sheet h4→p、task-manager h3→p、file-manager noFiles h3→p;新增 admin.logs.analytics.refresh i18n 键(en/zh)+merge-i18n-fragments。教训:settings 表单异步渲染,早前扫描漏报 label 违规需 wait 5s 后再 axe.run;Radix SelectValue value='' 时 placeholder 不显示致 combobox 无名,须 aria-label 兜底","setup":"审计环境同 run#54:后端:3100(sqlite) + docker redis host 网络 + pnpm dev --port 3002"}} +{"run":56,"commit":"63e3b85","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8","timestamp":1787720716912,"segment":0,"confidence":null,"asi":{"hypothesis":"富交互页抽查(websites/origins/proxy-routes/certificates/cloudflare/dns-accounts/settings 子页)","next_action_hint":"11 页扫描全部归零,a11y 维度已穷尽。剩余:周期性 -race 重跑;审计环境复用法在 ideas.md","result":"websites/origins/proxy-routes/certificates/dns-accounts 5 页直接 0 违规(主题修复覆盖);3 处新发现全修复并复扫验证:cloudflare 同步面板状态筛选 SelectTrigger 加 aria-label(statusPlaceholder);access-token 安全提示 amber-600→amber-700(12px 小字对比度 4.5 不达标);notifications 面包屑页加 sr-only h1——教训:h1 不能放 BreadcrumbList 内(破坏 list 语义 axe list 规则),BreadcrumbPage 无 asChild 需放 Breadcrumb 外","setup":"审计环境同前:后端:3100 + docker redis host 网络 + pnpm dev --port 3002"}} +{"run":57,"commit":"453f7e5","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理","timestamp":1787721299485,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 -race 重跑(上次干净为 run #49)","next_action_hint":"websocket 包 -race 10×count=1 全过。教训已记录:select 多 case 同时就绪时随机选择,closed 检查须独立 select 先行;replace 工具锚点选错会级联破坏文件,小文件直接 write 重写更安全","root_cause":"enqueue 把 closed 检查与发送合并在同一个 select,两 case 同时就绪时 Go 随机选择,close 后约 50% 概率仍投递成功——违反 fail-fast 契约且测试 flaky。修复=独立 select 确定性先查 done;测试加固为循环 50 次","result":"抓到真实 bug:wsClientCore.enqueue close 后非确定返回 true(TestWSClientCoreEnqueueFailsAfterClose 必失败)。调用方 agent_hub×3 语义无影响(false=丢弃本就正确)。顺带修 3 个 hub 文件存量 gofmt 漂移","scope_note":"-race 重跑仅 websocket 包 1 个 FAIL,其余 internal/... pkg/... 全部通过"}} +{"run":58,"commit":"fc733d0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复","timestamp":1787721635717,"segment":0,"confidence":null,"asi":{"hypothesis":"#57 修复 enqueue 后,grep 全 hub 同型合并 select——发现 SendFlaredPong/SendRelayPong 残留相同 bug","lesson":"修一个 bug 后应 grep 所有同型调用点(本会话 run #44/#46/#57 三次都是同型残留收口模式);委托共享 enqueue 是去重+根因一步到位","next_action_hint":"websocket 并发面已全清。下轮可做:周期性全仓 -race 或 go test -count=10 稳定性抽查","root_cause":"SendFlaredPong (flared_hub.go) 与 SendRelayPong (relay_hub.go) 把 case <-client.done 与 case client.send <- 合并同一 select,两 case 同时就绪时 Go 随机选择,close 后仍可能投递成功。修复=委托 client.enqueue(内含确定性先查 done),同时消除重复代码"}} +{"run":59,"commit":"b56f276","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复","timestamp":1787722520315,"segment":0,"confidence":null,"asi":{"hypothesis":"-shuffle=on 测试顺序随机化扫描(未查过的维度),暴露测试间共享状态依赖","lesson":"repository 读配置会写进程级 RAM 缓存(ram.Set,TTL 跨测试存活);测试用 :memory: DB + SetDB 换库时缓存不随之失效。默认源码顺序下 Defaults 先跑掩盖了问题。-shuffle=on 是暴露此类顺序依赖的低成本手段,可周期重跑","next_action_hint":"全仓 shuffle 已干净。下轮候选:-count 多轮稳定性、或从 ideas.md 剩余条目挑;明确不做清单见 ideas.md","root_cause":"TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 Custom 用例留在进程级 RAM 配置缓存的 enabled=false/[\"522\",\"500-502\"](GetSystemConfigByGroup 未命中时 ram.Set 回填)。修复=两个测试 setup(setupOriginErrorPageSnapshotDB/setupConfigVersionTestDB)接入既有 ram.ResetForTest():换 DB 前后各清一次"}} diff --git a/.auto/measure.sh b/.auto/measure.sh index 8b745939..5ee85c8e 100755 --- a/.auto/measure.sh +++ b/.auto/measure.sh @@ -1,46 +1,90 @@ #!/bin/bash -# Autoresearch measure — pinned yardstick. -# debt : findings under .auto/lint.ref.yaml (lower is better) [PRIMARY] -# nolint_dirs : raw //nolint directive count (lower is better, floor 0) -# tests_passed : go test packages passing (floor, must never drop) -# test_funcs : total Test*/Benchmark* funcs (floor, must never drop) -# arch_viol : Cordis architecture script violations (floor 0) -# coverage : backend statement coverage % (informational) -set -uo pipefail -cd "$(dirname "$0")/../backend" +# Benchmark: total code-quality issues across backend + frontend (lower is better). +# Fixed linter set — see .auto/prompt.md. Never tune this file to game counts. +set -euo pipefail +cd "$(dirname "$0")/.." +start=$(date +%s) -# Hermetic lint result cache. golangci-lint's default cache is machine-wide, so -# entries written while analysing a different worktree are replayed carrying that -# checkout's absolute paths, which misattributes findings and can serve a stale -# verdict. Key the cache to this directory. Count-neutral: cold and shared-warm -# runs both report the same number of findings. -GOLANGCI_LINT_CACHE="${TMPDIR:-/tmp}/ar-lint-cache-$(pwd | cksum | awk '{print $1}')" -export GOLANGCI_LINT_CACHE +# ---------- Backend: golangci-lint, repo config + fixed best-practice extras ---------- +EXTRA_LINTERS="errorlint,errname,nilnil,forcetypeassert,copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign,exhaustive" +golang_out=$(golangci-lint run --enable="$EXTRA_LINTERS" 2>&1 || true) -REF_CFG="$(cd .. && pwd)/.auto/lint.ref.yaml" +golang_total=0 +while IFS= read -r line; do + if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then + name="${BASH_REMATCH[1]}" + n="${BASH_REMATCH[2]}" + golang_total=$((golang_total + n)) + echo "METRIC golint_${name}=$n" + fi +done <<< "$golang_out" +echo "METRIC golint_total=$golang_total" -# Primary: pinned yardstick findings (never the mutable project config). -golangci-lint run -c "${REF_CFG}" > /tmp/ar_debt.txt 2>&1 || true -DEBT=$(grep -cE '\.go:[0-9]+:[0-9]+: ' /tmp/ar_debt.txt || true) +# ---------- Backend: test-code quality (tests excluded from repo config; safe linters only) ---------- +test_out=$(golangci-lint run --tests=true --enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper 2>&1 || true) +golang_test_total=0 +while IFS= read -r line; do + if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then + name="${BASH_REMATCH[1]}" + n="${BASH_REMATCH[2]}" + golang_test_total=$((golang_test_total + n)) + echo "METRIC golint_test_${name}=$n" + fi +done <<< "$test_out" +echo "METRIC golint_test_total=$golang_test_total" -# Suppression reliance — anti-cheat signal. -NOLINT=$(rg '//\s*nolint' --glob '*.go' 2>/dev/null | wc -l | tr -d ' ') +# ---------- Backend: govet extra analyzers (dead code / nil deref — real-bug finders) ---------- +cat > /tmp/govetx.yml <<'EOF' +version: "2" +linters: + default: none + enable: + - govet + settings: + govet: + enable: + - nilness + - unusedwrite +EOF +vetx_out=$(golangci-lint run --config /tmp/govetx.yml --max-issues-per-linter=0 2>&1 || true) +rm -f /tmp/govetx.yml +golang_vetx_total=0 +while IFS= read -r line; do + if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then + name="${BASH_REMATCH[1]}" + n="${BASH_REMATCH[2]}" + golang_vetx_total=$((golang_vetx_total + n)) + echo "METRIC golint_vetx_${name}=$n" + fi +done <<< "$vetx_out" +echo "METRIC golint_vetx_total=$golang_vetx_total" -# Regression floors. One covered run feeds both the pass floor and coverage. -go test -cover ./... > /tmp/ar_test.txt 2>&1 || true -TESTS_PASSED=$(grep -c '^ok' /tmp/ar_test.txt || true) -FAILS=$(grep -cE '^(FAIL|--- FAIL)' /tmp/ar_test.txt || true) -TEST_FUNCS=$(rg -c '^(func Test|func Benchmark)' --glob '*_test.go' 2>/dev/null | awk -F: '{s+=$2} END {print s+0}') +# ---------- Frontend: eslint (repo gate) ---------- +cd frontend +eslint_out=$(pnpm exec eslint . --max-warnings 0 2>&1 || true) +eslint_problems=0; eslint_errors=0; eslint_warnings=0 +if [[ "$eslint_out" =~ ([0-9]+)\ problems? ]]; then eslint_problems="${BASH_REMATCH[1]}"; fi +if [[ "$eslint_out" =~ \(([0-9]+)\ errors?, ]]; then eslint_errors="${BASH_REMATCH[1]}"; fi +if [[ "$eslint_out" =~ ,\ ([0-9]+)\ warnings? ]]; then eslint_warnings="${BASH_REMATCH[1]}"; fi +echo "METRIC eslint_problems=$eslint_problems" +echo "METRIC eslint_errors=$eslint_errors" +echo "METRIC eslint_warnings=$eslint_warnings" -# Cordis architecture violations (count of FAIL lines emitted by the gate script). -ARCH_VIOL=$("../scripts/check_cordis_architecture.sh" 2>&1 | grep -c 'FAIL' || true) +# ---------- Frontend: tsc (repo gate) ---------- +tsc_out=$(pnpm exec tsc --noEmit --jsx preserve 2>&1 || true) +tsc_errors=$(grep -cE "error TS" <<< "$tsc_out" || true) +echo "METRIC tsc_errors=$tsc_errors" -COVERAGE=$(grep -oE 'coverage: [0-9.]+%' /tmp/ar_test.txt | awk '{gsub("%","",$2); s+=$2; n++} END {if(n>0) printf "%.2f", s/n; else print "0"}') +# ---------- Frontend: vitest (2026-08-16 起全绿,纳入基准防回归) ---------- +vitest_out=$(pnpm exec vitest run --reporter=dot 2>&1 || true) +vitest_failed=0; vitest_total=0 +if [[ "$vitest_out" =~ ([0-9]+)\ failed ]]; then vitest_failed="${BASH_REMATCH[1]}"; fi +if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+)\ passed ]]; then vitest_total="${BASH_REMATCH[1]}"; fi +if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+) ]]; then vitest_total="${BASH_REMATCH[1]}"; fi +echo "METRIC vitest_failed=$vitest_failed" +echo "METRIC vitest_total=$vitest_total" -echo "METRIC debt=${DEBT}" -echo "METRIC nolint_dirs=${NOLINT}" -echo "METRIC tests_passed=${TESTS_PASSED}" -echo "METRIC test_funcs=${TEST_FUNCS}" -echo "METRIC arch_viol=${ARCH_VIOL}" -echo "METRIC coverage=${COVERAGE}" -echo "INFO test_failures=${FAILS}" +end=$(date +%s) +total=$((golang_total + golang_test_total + golang_vetx_total + eslint_problems + tsc_errors + vitest_failed)) +echo "METRIC total_issues=$total" +echo "METRIC measure_s=$((end - start))" \ No newline at end of file diff --git a/.auto/prompt.md b/.auto/prompt.md index bd38118b..5f0d1588 100644 --- a/.auto/prompt.md +++ b/.auto/prompt.md @@ -1,48 +1,169 @@ -# Autoresearch: Cordis 架构合规 + Bug 修复 + 性能 + 代码质量 +# Autoresearch: 前后端代码质量符合最佳代码实践 ## Objective -Wavelet 后端(Go, Cordis 插件化微内核架构)全面质量提升:修复违反 Cordis 设计原则的地方、修复 bug、消除潜在性能问题、减少代码重复(dupl)、提升可维护性。主指标为 golangci-lint 问题总数,逐实验递减,且不得以作弊手段(nolint / 弱化配置 / 删测试)达成。 + +Improve backend (Go) and frontend (Next.js/TS) code quality so the codebase +conforms to best practices. NOT a performance task. Each experiment is a code +change that removes real, lint-diagnosed code-quality issues (dead assignments, +error-wrapping bugs, non-idiomatic loops, mixed receivers, unsafe error +comparisons, unnecessary string fmt, etc.) without changing behavior. + +Genuine quality work only: fix code, never weaken the checks. Do NOT edit +`.golangci.yml`, eslint/biome config, or add `nolint`/`eslint-disable` +comments to reduce counts. Do NOT reformat code that isn't part of a fix +(no formatted-only churn). ## Metrics -- **Primary**: lint_issues (count, lower is better) — `golangci-lint run` 报告的问题总数(基线 45) -- **Secondary**: dup_issues (dupl 专项计数), tests_passed (go test 通过包数,不得下降) + +- **Primary**: `total_issues` (unitless, lower is better) = backend golangci + issues (extended linter set below) + frontend eslint problems + tsc errors. +- **Secondary**: per-linter counts (`golint_modernize`, `golint_perfsprint`, + `golint_errorlint`, `golint_gosec`, `golint_canonicalheader`, + `golint_recvcheck`, `golint_wastedassign`, `golint_usestdlibvars`, + `golint_intrange`, `golint_forcetypeassert`, `golint_nilnil`, + `golint_prealloc`, `golint_errname`, `golint_sloglint`, + `golint_copyloopvar`, `golint_mirror`, `golint_nosprintfhostport`), + `eslint_problems`, `eslint_errors`, `eslint_warnings`, `tsc_errors`, + `measure_s` (benchmark wall time). ## How to Run -`./.auto/measure.sh` — 输出 `METRIC lint_issues=N` / `METRIC dup_issues=N` / `METRIC tests_passed=N`。 + +`./.auto/measure.sh` — outputs `METRIC name=value` lines. Parsed by +run_experiment automatically. + +Correctness gate: `./.auto/checks.sh` runs `go vet ./...`, `go build ./...`, +and the repo's own `golangci-lint run` (repo config, tests excluded) — all +must pass. Note: `go test ./...` is NOT in checks.sh — several tests fail on +main today for environmental reasons (no local redis; flaky frpc process +tests). Don't "fix" those unless cheap and clearly unrelated to redis/flaky. + +## Benchmark Definition (fixed — never change mid-session) + +Backend: `golangci-lint run --enable=errorlint,errname,nilnil,forcetypeassert, +copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize, +sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign` +(repo `.golangci.yml` linters stay active too; `tests: false` as configured). + +Frontend: `pnpm exec eslint . --max-warnings 0` (repo gate) + +`pnpm exec tsc --noEmit --jsx preserve` (repo gate). + +Test-code dimension (added 2026-08-16, run #12+, documented scope extension — +raising the bar, not gaming): `golangci-lint run --tests=true +--enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper` +counts test-file quality. DELIBERATELY excludes paralleltest/tparallel +(t.Parallel advice is unsafe here: many suites share DB/redis state and tests +cannot be run in this env) and gocritic extras (noise). Fix test issues only +when compile-safe (go vet compiles tests) and semantically neutral. + +Frontend vitest dimension (added run #19, after suite went green in run #18): +`pnpm exec vitest run --reporter=dot` — `vitest_failed` counts into total. +The suite is fully runnable locally (jsdom + mocks; no external services). +Do not add/remove linters or change settings to make the number go down. ## Files in Scope -- `backend/core/` 微内核(context.go/container.go/events.go/app.go/extpoints/contracts)— 只允许更纯粹,禁止引入框架依赖 -- `backend/plugins/{domain,infra,drivers}/` 所有插件 — 重复代码消除、bug 修复 -- `backend/pkg/` 基础库 -- `scripts/check_cordis_architecture.sh` 架构守门脚本(只可增强、不可弱化) -- `.auto/*` 会话文件 -## Off Limits(作弊红线) -- ❌ 禁止修改 `.golangci.yml` 弱化 lint(禁阈值调高、禁关 linter、禁 excludes) -- ❌ 禁止 `//nolint` 注释压制问题 -- ❌ 禁止删除测试或功能来消除告警(删除死代码需先证明确实无人引用) -- ❌ 禁止违反 `backend/pkg/util/` 纯净性、`backend/core/` 微内核纯净性、contracts 纯抽象 -- ❌ 修复行为 bug 必须带回归测试或明确论证;tests_passed 不得下降 -- ✅ 允许:重构提取共享 helper(插件内)、加注释、常量化字符串、拆复杂嵌套、修 gosec 权限、优化 SQL/锁/分配 +Backend (Go): `cmd/`, `internal/`, `pkg/`. Anything lint-flagged in the +extended set above. Note: module name in go.mod is `github.com/Rain-kl/Wavelet`. + +Frontend (TS/React): `frontend/app/`, `frontend/components/`, `frontend/lib/`, +`frontend/contexts/`, `frontend/hooks/`, `frontend/types/`, frontend scripts. + +Infra: `frontend/pnpm-workspace.yaml` — approved @parcel/watcher + @swc/core +builds (fixes `make code-check` under pnpm 11; ERR_PNPM_IGNORED_BUILDS +otherwise). Already committed in setup. + +## Off Limits + +- `.golangci.yml`, `eslint.config.mjs`, `biome.json` — never touch to reduce counts. +- No `//nolint` / `eslint-disable` comments to silence checks. +- No reformat-only commits (biome/gofmt churn without a fix). +- No behavior changes: refactors must compile (checks.sh gate) and keep tests + semantics identical. Re-run checks.sh after every edit. +- `frontend/node_modules`, `frontend/bun.lock` (untracked, not ours). +- Do not run `go test` suites that need redis/network to declare success. ## Constraints -- `.auto/checks.sh` 必须通过:`go build` + `go test ./...` + Cordis 架构检查全绿 -- 插件间严禁跨包 import,只能走 `core/contracts` + EventBus -- 裸 `go func()` 禁止,统一 `util.Go`;SQL LIKE 必须 `util.EscapeLike` + `ESCAPE '\\'` -- API Handler 改动后跑 `make swagger`(若改了 handler 签名/路由) + +- Backend conventions (AGENTS.md): apps → repository → model layering; + `pkg/util/` must not import Gin/GORM/sessions; no `db.DB` in model; + response.Abort* for API errors; Chinese docs for content changes + (code-quality fixes are not content changes — no doc sync needed unless + behavior/UX changes; changelog only for user-visible changes, typically + none here). +- Frontend: run `pnpm exec biome format --write` only on files you edit + (repo `make format` uses biome); keep component placement rules. +- `golangci-lint --fix` is allowed and preferred for safe fixes + (modernize/intrange/perfsprint/usestdlibvars/canonicalheader/mirror/ + copyloopvar/sloglint/errname) — review the resulting diff before keeping. + For no-fix linters (errorlint wrapping, wastedassign, recvcheck, nilnil, + prealloc, forcetypeassert) edit by hand. + +## Workflow per iteration + +1. Read current measure output: which categories remain, where. +2. Pick ONE category (or a coherent set of similar fixes), locate files, fix + by hand or with golangci-lint --fix scoped to that category. +3. `./.auto/measure.sh` → if total dropped → `./.auto/checks.sh` → log keep. + If flat/worse → discard or adjust. ## What's Been Tried -### 基线状态(2026-08-28, 45 lint issues) -- **nilerr 真 bug 候选**: `backend/plugins/domain/admin/repository.go:507` err!=nil 却 return nil -- **contextcheck**: `driver_inproc_cron/plugin.go:70`、`driver_inproc_worker/plugin.go:133` 未传 context -- **dupl 重复块**: core/extpoints{migration,setting,schedule,task} 四处同构注册代码;message_gateway{admin_handlers:55-67,115-130,143-158 ↔ push_handlers:61-74,77-93 ↔ push_channels:218-231,270-286}; message_gateway/repository.go:222-240↔332-350; driver_asynq_worker/plugin.go:371-390↔420-439 -- **nestif 深嵌套**: admin/handlers_config.go:442(complexity 6), upload/filesrv/file_server.go:330(6), driver_asynq_cron/plugin.go:142(9), driver_asynq_cron/scheduler.go:119(5) -- **gosec**: upload/shared/test_helpers.go:133(G301),134(G306),152(G304); infra/database/postgres.go:49(G301) -- **goconst**: "sqlite"(admin/handlers_db.go:150,handlers_status.go:179), "upload"/"default"(upload/task/*) -- **revive**: storage.go:53 缺注释, db_helper.go{admin:118,125; message_gateway:25; upload/storage/migration.go:40} 未用参数, handlers_config.go:510 未用参数 -- **mnd**: pkg/cache/disk/cache.go:100 魔法数 10 -- **gofumpt**: upload/stats/{category,stats_counter}.go, driver_http/db_helper.go 格式错误 -- 前端 eslint/tsc 已全绿;架构检查脚本 0 违规 -### 教训 -- (空 — 随实验更新) +- Setup commit `ee6974d` (autoresearch/code-quality-2026-08-16): branch, + .auto/ session files, frontend/pnpm-workspace.yaml build approvals. +- Baseline (before any code fix): total_issues = 108 + (golangci 107 = modernize 37, perfsprint 18, errorlint 12, canonicalheader 8, + recvcheck 7, wastedassign 7, usestdlibvars 3, intrange 3, forcetypeassert 3, + nilnil 3, prealloc 3, errname 1, gosec 2; eslint 1 warning + [react-hooks/exhaustive-deps in + app/(main)/pages/detail/components/pages-source-card.tsx:275]; tsc 0). +- Environment notes: golangci-lint 2.12.2 warm cache ~3s; eslint cold ~27s + (ignore stderr pnpm noise); go vet+go build ~15-30s after edits. + +### 最终状态(run #23,提交 aa4fadda,本会话收敛点) + +基准 5 维全下限 total=8(全为刻意保留);后端 94 包 + 前端 vitest 116 全绿; +`go test -race ./internal/... ./pkg/...` 93 包零警告;`make build-embedded` +(发布路径)成功且工作树干净;`make license-check` / `go mod tidy -diff` / +`go test -count=3`(时序敏感包)全部通过。checks.sh 门禁:vet + build + +golangci + 单测 + vitest + 并发包 -race + license-check。 + +### Session result (14 experiments, commits f1f6bb85→65c02ef7) + +108 → **8** (-92.6%) across 3 benchmark dimensions, all remaining 8 are +deliberate, documented keepers (see below). Never weakened a check; never +added nolint/eslint-disable; benchmark extensions were transparently +documented (test-code dimension run #12, exhaustive run #14). + +Fixed (zero behavior change, each reviewed): +- gosec 2→0 (saturating multiply pattern gosec accepts without nolint) +- modernize 37→5→3 (any, max/min, slices/maps, strings.Cut/SplitSeq, + strings.Builder; omitted omitted-lark: nested struct omitzero = wire change) +- perfsprint 18→0, canonicalheader 8→0, usestdlibvars 3→0, intrange 3→0, + wastedassign 7→0, errname 1→0, forcetypeassert 6→0, prealloc 2→0 +- errorlint 12→1 (errors.Is/As, %v→%w chains) +- recvcheck 7→1 (GORM TableName → pointer receiver; verified gorm source uses + reflect.New, tests pass) +- eslint 1→0 (exhaustive-deps: add stable `t` to dep array) +- test dimension 25→0 (testifylint 20, thelper 3, usetesting 2) +- exhaustive 12→0 (explicit enum cases = fail-explicit) + +Deliberate keepers (8) — do NOT "fix" without new evidence: +- errorlint 1: pkg/push/telegram.go %v — wrapping the original error would + change errors.Is matching semantics; it's intentionally textual context. +- modernize 3: nested-struct omitempty (client.go Release/Asset, + lark.go Content) — omitzero would CHANGE wire output (plain structs + serialize always today). +- nilnil 3: not-found/optional-result conventions — postgres_store.go + ClickHouseOperationalStats (interface contract, documented in comment), + openflare_apply_log.go GetLatestOpenFlareApplyLogByNodeID (tested), + github_source_action.go guarded outcome (callers check != nil). +- recvcheck 1: MillisecondDuration — encoding/json requires Marshal value + receiver + Unmarshal pointer receiver. + +Surveyed and rejected (noise/risk, do not add): +- fieldalignment (~100+): JSON key order change + positional literal risk. +- sloglint full / gocritic extras: 0 findings. +- paralleltest/tparallel: t.Parallel advice unsafe (shared DB/redis state; + tests not runnable in this env). +- biome format drift (76 files): pure formatting noise; repo's make format + covers it. \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index 65420584..f5528fd0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,21 +1,27 @@ .git .idea .vscode +.github +anubis-source +**/node_modules +**/.next +**/build +**/dist +**/.cache +**/coverage +**/*.db +**/*.log +tmp +logs .DS_Store -Thumbs.db - -config.yaml .env .env.* - +docker-compose*.yml +config.yaml bin/ -build/ -dist/ data/ -logs/ uploads/ s3_cache/ - frontend/node_modules/ frontend/.next/ frontend/out/ @@ -25,6 +31,6 @@ frontend/.env frontend/next-env.d.ts frontend/*.tsbuildinfo frontend/package-lock.json - internal/router/dist/ internal/router/root/dist/ +backend/plugins/drivers/driver_http/dist/ diff --git a/.env.example b/.env.example index a2f4a02b..e0de1b0c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ────────────────────────────────────────────────────────────────────────────── -# wavelet — 环境变量配置模板 +# openflare — 环境变量配置模板 # 复制此文件为 .env 并填入实际值: cp .env.example .env # 环境变量优先级高于 config.yaml # docker compose 会读取本文件(env_file: .env)并替换 compose 中的 ${VAR} @@ -9,13 +9,13 @@ TZ=Asia/Shanghai # ─── 应用配置 ────────────────────────────────────────────────────────────────── -APP_NAME=wavelet +APP_NAME=openflare APP_ENV=production -APP_ADDR=:8000 +APP_ADDR=:3000 APP_NODE_ID=1 APP_API_PREFIX=/api # APP_GRACEFUL_SHUTDOWN_TIMEOUT=30 -APP_SESSION_COOKIE_NAME=wavelet_session_id +APP_SESSION_COOKIE_NAME=openflare_session_id APP_SESSION_SECRET=change-me-to-a-random-string-in-production # APP_SESSION_DOMAIN= APP_SESSION_AGE=86400 @@ -27,12 +27,13 @@ APP_SESSION_SECURE=true # 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制 # DB_ENABLED=false 时使用 SQLite 作为后备数据库 DB_ENABLED=true -# SQLITE_PATH=./data/wavelet.db +# SQLITE_PATH=./data/openflare.db +# compose 内应用连服务名;本机直连 Docker 映射端口时用 127.0.0.1 DB_HOST=postgres DB_PORT=5432 -DB_USERNAME=postgres -DB_PASSWORD=postgres -DB_NAME=wavelet +DB_USERNAME=openflare +DB_PASSWORD=replace-with-strong-password +DB_NAME=openflare DB_SSL_MODE=disable DB_TIMEZONE=Asia/Shanghai # DB_LOG_LEVEL=info @@ -46,20 +47,23 @@ REDIS_ADDR=redis:6379 # REDIS_USERNAME= # REDIS_PASSWORD= # REDIS_DB=0 -REDIS_KEY_PREFIX=wavelet: +REDIS_KEY_PREFIX=openflare: # REDIS_POOL_SIZE=100 # 启动时开关;修改后需重启服务 REDIS_MAINT_NOTIFICATIONS=false # compose 宿主机映射端口(仅 docker-compose 使用) # REDIS_PORT=6379 -# ─── ClickHouse(可选,默认关闭)────────────────────────────────────────── -# 设置 CLICKHOUSE_HOST 后自动启用,也可显式控制 -# CLICKHOUSE_ENABLED=false -# CLICKHOUSE_HOST=clickhouse:9000 -# CLICKHOUSE_USERNAME=default -# CLICKHOUSE_PASSWORD= -# CLICKHOUSE_NAME=wavelet +# ─── ClickHouse(必需)──────────────────────────────────────────────────────── +# CLICKHOUSE_HOST 设置后会自动启用;测试环境可显式 CLICKHOUSE_ENABLED=true 做 live 联调 +CLICKHOUSE_ENABLED=false +# compose 内:clickhouse:9000;本机连映射端口:127.0.0.1:9000 +CLICKHOUSE_HOST=clickhouse:9000 +CLICKHOUSE_USERNAME=default +# 须与 compose clickhouse 服务密码一致(首次初始化后改密码需清 data/clickhouse_data) +CLICKHOUSE_PASSWORD=replace-with-clickhouse-password +CLICKHOUSE_NAME=openflare + # ─── 日志 ────────────────────────────────────────────────────────────────────── LOG_LEVEL=info @@ -72,8 +76,8 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317 OTEL_EXPORTER_OTLP_INSECURE=true # 设为 0 关闭 tracing;本地 Jaeger 调试建议设为 1.0 OTEL_SAMPLING_RATE=0.0 -# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/Wavelet -# OTEL_TRACER_NAME=github.com/Rain-kl/Wavelet +# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare +# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare # compose 可选端口覆盖 # JAEGER_VERSION=2.19.0 # JAEGER_UI_PORT=16686 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..0ab2baad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* -text +backend/openflare/** merge=ours +frontend/** merge=ours +docs/changelog/** merge=ours +docs/superpowers/** merge=ours +.github/workflows/build-image.yml merge=ours +docker-compose.yml merge=ours +.gitconfig merge=ours diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 00000000..b5d615c3 --- /dev/null +++ b/.gitconfig @@ -0,0 +1,10 @@ +# Repo-local Git settings. Git does not load this file automatically. +# From the clone (or worktree) root: +# git config include.path ../.gitconfig +# Worktree-safe: +# git config include.path "$(git rev-parse --show-toplevel)/.gitconfig" +# Relative include.path is resolved against .git/config, so ../.gitconfig +# is the repo root when .git is a directory (non-worktree clone). + +[merge "ours"] + driver = true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index df38280e..2e543a9c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,7 +12,7 @@ - 新增功能时考虑向后兼容性和 API 稳定性 - 遵循项目的 Apache2.0 许可证要求 - 遵循语义化版本控制规范 -- 新增异步任务时使用项目技能 `.agents/new-async-task/SKILL.md` +- 新增异步任务时使用项目技能 `.agent/new-async-task/SKILL.md` ## 后端规范 diff --git a/.github/workflows/build-image-openflare-agent.yml b/.github/workflows/build-image-openflare-agent.yml new file mode 100644 index 00000000..7397bd31 --- /dev/null +++ b/.github/workflows/build-image-openflare-agent.yml @@ -0,0 +1,197 @@ +name: Build Image (openflare-agent) + +on: + workflow_dispatch: + inputs: + version: + description: "Image version/tag to publish, for example v1.0.0-beta" + required: false + type: string + push: + tags: ["v*"] + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +env: + IMAGE_NAME: openflare-agent + DOCKERFILE: docker/Dockerfile.agent + +jobs: + build: + name: Build (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Set image metadata + shell: bash + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)" + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ -n "$POINTED_TAG" ]]; then + VERSION="$POINTED_TAG" + else + echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2 + exit 1 + fi + + echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ env.DOCKERFILE }} + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + build-args: | + VERSION=${{ env.VERSION }} + cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + + - name: Export digest + shell: bash + run: | + mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests" + touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }} + path: /tmp/${{ env.IMAGE_NAME }}-digests/* + if-no-files-found: error + retention-days: 1 + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-24.04 + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Set image metadata + shell: bash + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)" + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ -n "$POINTED_TAG" ]]; then + VERSION="$POINTED_TAG" + else + echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2 + exit 1 + fi + + echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/${{ env.IMAGE_NAME }}-digests + pattern: ${{ env.IMAGE_NAME }}-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + working-directory: /tmp/${{ env.IMAGE_NAME }}-digests + shell: bash + run: | + shopt -s nullglob + references=() + for digest in *; do + references+=("${IMAGE}@sha256:${digest}") + done + + if [ ${#references[@]} -eq 0 ]; then + echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2 + exit 1 + fi + + if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then + FLOATING_TAG="beta" + else + FLOATING_TAG="latest" + fi + + docker buildx imagetools create \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:${FLOATING_TAG}" \ + "${references[@]}" + env: + IMAGE: ${{ env.IMAGE }} + + - name: Inspect image + run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}" \ No newline at end of file diff --git a/.github/workflows/build-image-openflare-relay.yml b/.github/workflows/build-image-openflare-relay.yml new file mode 100644 index 00000000..289685dc --- /dev/null +++ b/.github/workflows/build-image-openflare-relay.yml @@ -0,0 +1,197 @@ +name: Build Image (openflare-relay) + +on: + workflow_dispatch: + inputs: + version: + description: "Image version/tag to publish, for example v1.0.0-beta" + required: false + type: string + push: + tags: ["v*"] + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +env: + IMAGE_NAME: openflare-relay + DOCKERFILE: docker/Dockerfile.relay + +jobs: + build: + name: Build (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Set image metadata + shell: bash + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)" + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ -n "$POINTED_TAG" ]]; then + VERSION="$POINTED_TAG" + else + echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2 + exit 1 + fi + + echo "IMAGE=ghcr.io/${OWNER}/openflare-relay" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ env.DOCKERFILE }} + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + build-args: | + VERSION=${{ env.VERSION }} + cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + + - name: Export digest + shell: bash + run: | + mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests" + touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }} + path: /tmp/${{ env.IMAGE_NAME }}-digests/* + if-no-files-found: error + retention-days: 1 + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-24.04 + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Set image metadata + shell: bash + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)" + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ -n "$POINTED_TAG" ]]; then + VERSION="$POINTED_TAG" + else + echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2 + exit 1 + fi + + echo "IMAGE=ghcr.io/${OWNER}/openflare-relay" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/${{ env.IMAGE_NAME }}-digests + pattern: ${{ env.IMAGE_NAME }}-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + working-directory: /tmp/${{ env.IMAGE_NAME }}-digests + shell: bash + run: | + shopt -s nullglob + references=() + for digest in *; do + references+=("${IMAGE}@sha256:${digest}") + done + + if [ ${#references[@]} -eq 0 ]; then + echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2 + exit 1 + fi + + if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then + FLOATING_TAG="beta" + else + FLOATING_TAG="latest" + fi + + docker buildx imagetools create \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:${FLOATING_TAG}" \ + "${references[@]}" + env: + IMAGE: ${{ env.IMAGE }} + + - name: Inspect image + run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}" \ No newline at end of file diff --git a/.github/workflows/build-image-openflare.yml b/.github/workflows/build-image-openflare.yml new file mode 100644 index 00000000..27d4e818 --- /dev/null +++ b/.github/workflows/build-image-openflare.yml @@ -0,0 +1,270 @@ +name: Build Image (openflare) + +on: + workflow_dispatch: + inputs: + version: + description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary." + required: false + type: string + push: + tags: ["v*"] + branches: ["canary"] + +# One active run per ref (e.g. canary); newer runs cancel older in-progress builds. +concurrency: + group: build-image-openflare-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +env: + IMAGE_NAME: openflare + DOCKERFILE: docker/Dockerfile + +jobs: + # Resolve version / registries once. No checkout: triggers alone determine the tag. + prepare: + name: Prepare metadata + runs-on: ubuntu-latest + outputs: + version: ${{ steps.prep.outputs.version }} + build_date: ${{ steps.prep.outputs.build_date }} + image: ${{ steps.prep.outputs.image }} + image_names: ${{ steps.prep.outputs.image_names }} + images: ${{ steps.prep.outputs.images }} + push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }} + is_stable: ${{ steps.prep.outputs.is_stable }} + is_prerelease: ${{ steps.prep.outputs.is_prerelease }} + steps: + - name: Resolve version and images + id: prep + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }} + run: | + set -euo pipefail + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + + if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then + VERSION="canary" + elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + VERSION="canary" + else + echo "unable to determine image version/tag" >&2 + exit 1 + fi + + if [[ "$VERSION" == "canary" ]]; then + IS_STABLE="false" + IS_PRERELEASE="false" + elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then + IS_STABLE="false" + IS_PRERELEASE="true" + else + IS_STABLE="true" + IS_PRERELEASE="false" + fi + + IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}" + IMAGE_NAMES="${IMAGE}" + # Newline-separated list for docker/metadata-action + IMAGES="${IMAGE}" + + DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}" + DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}" + DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}" + PUSH_DOCKERHUB="false" + if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then + HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}" + HUB_NS="${HUB_NS,,}" + IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}" + IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}" + IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}" + PUSH_DOCKERHUB="true" + echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}" + else + echo "Docker Hub secrets not set; publishing to GHCR only." + fi + + { + echo "version=${VERSION}" + echo "build_date=${BUILD_DATE}" + echo "image=${IMAGE}" + echo "image_names=${IMAGE_NAMES}" + echo "push_dockerhub=${PUSH_DOCKERHUB}" + echo "is_stable=${IS_STABLE}" + echo "is_prerelease=${IS_PRERELEASE}" + echo "images<> "$GITHUB_OUTPUT" + + echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}" + + build: + name: Build (${{ matrix.arch }}) + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + # No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image. + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log into Docker Hub + if: needs.prepare.outputs.push_dockerhub == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ env.DOCKERFILE }} + platforms: ${{ matrix.platform }} + outputs: type=image,"name=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + BUILD_DATE=${{ needs.prepare.outputs.build_date }} + cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + + - name: Export digest + shell: bash + run: | + mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests" + touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }} + path: /tmp/${{ env.IMAGE_NAME }}-digests/* + if-no-files-found: error + retention-days: 1 + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ needs.prepare.outputs.image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-latest + needs: [prepare, build] + steps: + # No repo checkout: tags come from prepare + metadata-action. + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ needs.prepare.outputs.images }} + flavor: | + latest=false + tags: | + type=raw,value=${{ needs.prepare.outputs.version }} + type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }} + type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }} + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/${{ env.IMAGE_NAME }}-digests + pattern: ${{ env.IMAGE_NAME }}-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log into Docker Hub + if: needs.prepare.outputs.push_dockerhub == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create and push manifest list + working-directory: /tmp/${{ env.IMAGE_NAME }}-digests + shell: bash + env: + IMAGE: ${{ needs.prepare.outputs.image }} + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} + run: | + set -euo pipefail + shopt -s nullglob + references=() + for digest in *; do + references+=("${IMAGE}@sha256:${digest}") + done + + if [ ${#references[@]} -eq 0 ]; then + echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2 + exit 1 + fi + + # shellcheck disable=SC2046 + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + "${references[@]}" + + - name: Inspect image + run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}" + + - name: Trigger webhook + env: + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + run: | + if [ -n "$WEBHOOK_URL" ]; then + curl -fsSL "$WEBHOOK_URL" + else + echo "Webhook URL is not set, skipping." + fi diff --git a/.github/workflows/build-image-openflared.yml b/.github/workflows/build-image-openflared.yml new file mode 100644 index 00000000..753b75b6 --- /dev/null +++ b/.github/workflows/build-image-openflared.yml @@ -0,0 +1,197 @@ +name: Build Image (openflared) + +on: + workflow_dispatch: + inputs: + version: + description: "Image version/tag to publish, for example v1.0.0-beta" + required: false + type: string + push: + tags: ["v*"] + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +env: + IMAGE_NAME: openflared + DOCKERFILE: docker/Dockerfile.flared + +jobs: + build: + name: Build (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Set image metadata + shell: bash + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)" + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ -n "$POINTED_TAG" ]]; then + VERSION="$POINTED_TAG" + else + echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2 + exit 1 + fi + + echo "IMAGE=ghcr.io/${OWNER}/openflared" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ env.DOCKERFILE }} + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + build-args: | + VERSION=${{ env.VERSION }} + cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + + - name: Export digest + shell: bash + run: | + mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests" + touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }} + path: /tmp/${{ env.IMAGE_NAME }}-digests/* + if-no-files-found: error + retention-days: 1 + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-24.04 + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + + - name: Set image metadata + shell: bash + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)" + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ -n "$POINTED_TAG" ]]; then + VERSION="$POINTED_TAG" + else + echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2 + exit 1 + fi + + echo "IMAGE=ghcr.io/${OWNER}/openflared" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/${{ env.IMAGE_NAME }}-digests + pattern: ${{ env.IMAGE_NAME }}-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + working-directory: /tmp/${{ env.IMAGE_NAME }}-digests + shell: bash + run: | + shopt -s nullglob + references=() + for digest in *; do + references+=("${IMAGE}@sha256:${digest}") + done + + if [ ${#references[@]} -eq 0 ]; then + echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2 + exit 1 + fi + + if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then + FLOATING_TAG="beta" + else + FLOATING_TAG="latest" + fi + + docker buildx imagetools create \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:${FLOATING_TAG}" \ + "${references[@]}" + env: + IMAGE: ${{ env.IMAGE }} + + - name: Inspect image + run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}" \ No newline at end of file diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 16adf69d..eb118760 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -1,270 +1,22 @@ -name: Build Image +name: Build Image (Wavelet upstream — isolated) +# Isolated: OpenFlare publishes images via build-image-openflare.yml +# (IMAGE_NAME: openflare). This Wavelet workflow is kept under the same +# path so `git merge wavelet/main` cannot restore a canary wavelet image. on: workflow_dispatch: inputs: - version: - description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary." - required: false - type: string - push: - tags: ["v*"] - branches: ["canary"] - -# One active run per ref (e.g. canary); newer runs cancel older in-progress builds. -concurrency: - group: build-image-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - packages: write - attestations: write - id-token: write - -env: - IMAGE_NAME: wavelet - DOCKERFILE: docker/Dockerfile + confirm: + description: "Disabled on OpenFlare. Use build-image-openflare.yml." + required: true jobs: - # Resolve version / registries once. No checkout: triggers alone determine the tag. - prepare: - name: Prepare metadata + isolated: + name: Isolated runs-on: ubuntu-latest - outputs: - version: ${{ steps.prep.outputs.version }} - build_date: ${{ steps.prep.outputs.build_date }} - image: ${{ steps.prep.outputs.image }} - image_names: ${{ steps.prep.outputs.image_names }} - images: ${{ steps.prep.outputs.images }} - push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }} - is_stable: ${{ steps.prep.outputs.is_stable }} - is_prerelease: ${{ steps.prep.outputs.is_prerelease }} steps: - - name: Resolve version and images - id: prep - env: - INPUT_VERSION: ${{ github.event.inputs.version }} - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }} + - name: Refuse Wavelet image publish run: | - set -euo pipefail - INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" - OWNER="${GITHUB_REPOSITORY_OWNER,,}" - BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" - - if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then - VERSION="canary" - elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then - VERSION="${GITHUB_REF_NAME}" - elif [[ -n "$INPUT_VERSION" ]]; then - VERSION="$INPUT_VERSION" - elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - VERSION="canary" - else - echo "unable to determine image version/tag" >&2 - exit 1 - fi - - if [[ "$VERSION" == "canary" ]]; then - IS_STABLE="false" - IS_PRERELEASE="false" - elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then - IS_STABLE="false" - IS_PRERELEASE="true" - else - IS_STABLE="true" - IS_PRERELEASE="false" - fi - - IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}" - IMAGE_NAMES="${IMAGE}" - # Newline-separated list for docker/metadata-action - IMAGES="${IMAGE}" - - DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}" - DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}" - DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}" - PUSH_DOCKERHUB="false" - if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then - HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}" - HUB_NS="${HUB_NS,,}" - IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}" - IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}" - IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}" - PUSH_DOCKERHUB="true" - echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}" - else - echo "Docker Hub secrets not set; publishing to GHCR only." - fi - - { - echo "version=${VERSION}" - echo "build_date=${BUILD_DATE}" - echo "image=${IMAGE}" - echo "image_names=${IMAGE_NAMES}" - echo "push_dockerhub=${PUSH_DOCKERHUB}" - echo "is_stable=${IS_STABLE}" - echo "is_prerelease=${IS_PRERELEASE}" - echo "images<> "$GITHUB_OUTPUT" - - echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}" - - build: - name: Build (${{ matrix.arch }}) - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-latest - - arch: arm64 - platform: linux/arm64 - # No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image. - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log into GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Log into Docker Hub - if: needs.prepare.outputs.push_dockerhub == 'true' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - id: build - uses: docker/build-push-action@v7 - with: - context: . - file: ${{ env.DOCKERFILE }} - platforms: ${{ matrix.platform }} - outputs: type=image,"name=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true - build-args: | - VERSION=${{ needs.prepare.outputs.version }} - BUILD_DATE=${{ needs.prepare.outputs.build_date }} - cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} - cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} - - - name: Export digest - shell: bash - run: | - mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests" - touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}" - env: - DIGEST: ${{ steps.build.outputs.digest }} - - - name: Upload digest - uses: actions/upload-artifact@v4 - with: - name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }} - path: /tmp/${{ env.IMAGE_NAME }}-digests/* - if-no-files-found: error - retention-days: 1 - - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v3 - with: - subject-name: ${{ needs.prepare.outputs.image }} - subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true - - merge: - name: Merge multi-arch manifest - runs-on: ubuntu-latest - needs: [prepare, build] - steps: - # No repo checkout: tags come from prepare + metadata-action. - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ needs.prepare.outputs.images }} - flavor: | - latest=false - tags: | - type=raw,value=${{ needs.prepare.outputs.version }} - type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }} - type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }} - - - name: Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/${{ env.IMAGE_NAME }}-digests - pattern: ${{ env.IMAGE_NAME }}-digests-* - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log into GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Log into Docker Hub - if: needs.prepare.outputs.push_dockerhub == 'true' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create and push manifest list - working-directory: /tmp/${{ env.IMAGE_NAME }}-digests - shell: bash - env: - IMAGE: ${{ needs.prepare.outputs.image }} - DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} - run: | - set -euo pipefail - shopt -s nullglob - references=() - for digest in *; do - references+=("${IMAGE}@sha256:${digest}") - done - - if [ ${#references[@]} -eq 0 ]; then - echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2 - exit 1 - fi - - # shellcheck disable=SC2046 - docker buildx imagetools create \ - $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - "${references[@]}" - - - name: Inspect image - run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}" - - - name: Trigger webhook - env: - WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} - run: | - if [ -n "$WEBHOOK_URL" ]; then - curl -fsSL "$WEBHOOK_URL" - else - echo "Webhook URL is not set, skipping." - fi + echo "This Wavelet image workflow is isolated on OpenFlare." + echo "Use .github/workflows/build-image-openflare.yml" + exit 1 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 796c6330..dacde310 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -11,7 +11,7 @@ on: type: string env: - APP_NAME: wavelet + APP_NAME: openflare-server GO_DIR: backend GO_MAIN: ./main.go GO_BUILD_TAGS: embed_frontend @@ -146,6 +146,7 @@ jobs: rm -rf "$EMBED_DIST_DIR" mkdir -p "$(dirname "$EMBED_DIST_DIR")" cp -R "$FRONTEND_OUT_DIR" "$EMBED_DIST_DIR" + test -f "$EMBED_DIST_DIR/index.html" - name: Upload embedded frontend uses: actions/upload-artifact@v4 @@ -272,3 +273,148 @@ jobs: with: tag_name: ${{ needs.create-release.outputs.version }} files: ${{ steps.package.outputs.artifact }} + + build-agent-binaries: + name: Build agent ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + needs: create-release + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + asset_name: openflare-agent-linux-amd64 + - goos: linux + goarch: arm64 + asset_name: openflare-agent-linux-arm64 + - goos: darwin + goarch: amd64 + asset_name: openflare-agent-darwin-amd64 + - goos: darwin + goarch: arm64 + asset_name: openflare-agent-darwin-arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: ${{ env.GO_DIR }}/go.mod + + # GeoIP MMDB is not embedded; Docker images COPY mmdb files, bare binaries seed via download on first start. + - name: Build Agent + env: + CGO_ENABLED: 0 + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + ASSET_NAME: ${{ matrix.asset_name }} + VERSION: ${{ needs.create-release.outputs.version }} + run: | + cd backend + go mod download + mkdir -p "$GITHUB_WORKSPACE/dist" + go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/agent/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/agent/main.go + + - name: Upload release artifact + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.create-release.outputs.version }} + files: dist/${{ matrix.asset_name }} + + build-relay-binaries: + name: Build relay ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + needs: create-release + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + asset_name: openflare-relay-linux-amd64 + - goos: linux + goarch: arm64 + asset_name: openflare-relay-linux-arm64 + - goos: darwin + goarch: amd64 + asset_name: openflare-relay-darwin-amd64 + - goos: darwin + goarch: arm64 + asset_name: openflare-relay-darwin-arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: ${{ env.GO_DIR }}/go.mod + + - name: Build Relay + env: + CGO_ENABLED: 0 + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + ASSET_NAME: ${{ matrix.asset_name }} + VERSION: ${{ needs.create-release.outputs.version }} + run: | + cd backend + go mod download + mkdir -p "$GITHUB_WORKSPACE/dist" + go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/relay/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/relay/main.go + + - name: Upload release artifact + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.create-release.outputs.version }} + files: dist/${{ matrix.asset_name }} + + build-flared-binaries: + name: Build flared ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + needs: create-release + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + asset_name: openflared-linux-amd64 + - goos: linux + goarch: arm64 + asset_name: openflared-linux-arm64 + - goos: darwin + goarch: amd64 + asset_name: openflared-darwin-amd64 + - goos: darwin + goarch: arm64 + asset_name: openflared-darwin-arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: ${{ env.GO_DIR }}/go.mod + + - name: Build Flared + env: + CGO_ENABLED: 0 + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + ASSET_NAME: ${{ matrix.asset_name }} + VERSION: ${{ needs.create-release.outputs.version }} + run: | + cd backend + go mod download + mkdir -p "$GITHUB_WORKSPACE/dist" + go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/flared/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/flared/main.go + + - name: Upload release artifact + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.create-release.outputs.version }} + files: dist/${{ matrix.asset_name }} \ No newline at end of file diff --git a/.github/workflows/close_ticket.yml b/.github/workflows/close_ticket.yml new file mode 100644 index 00000000..4ebf1f4a --- /dev/null +++ b/.github/workflows/close_ticket.yml @@ -0,0 +1,24 @@ +name: Close Ticket + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + close_ticket: + runs-on: ubuntu-24.04 + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v9 + with: + days-before-issue-stale: 14 + days-before-issue-close: 14 + stale-issue-message: "此 issue 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复" + close-issue-message: "此 issue 因长期无活动已自动关闭,如有需要请重新开启" + days-before-pr-stale: 14 + days-before-pr-close: 14 + stale-pr-message: "此 PR 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复" + close-pr-message: "此 PR 因长期无活动已自动关闭,如有需要请重新开启" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..dfc6162e --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,48 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-24.04 + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install JavaScript dependencies + working-directory: frontend + run: pnpm install + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + check-latest: true + + - name: Install dependencies + run: | + go mod download + go install github.com/swaggo/swag/cmd/swag@v1.16.6 diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml new file mode 100644 index 00000000..e61365de --- /dev/null +++ b/.github/workflows/pr-template-check.yml @@ -0,0 +1,32 @@ +name: Check PR Template Checklist + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + check-pr-template: + runs-on: ubuntu-24.04 + steps: + - name: check all checklist items are checked + uses: actions/github-script@v7 + with: + script: | + // get the pull request body + const prBody = context.payload.pull_request.body || ''; + + // regex to match all checklist items in the template + // matches lines like: - [ ] ... or - [x] ... + const checklistRegex = /^- \[( |x|X)\] .+$/gm; + const matches = prBody.match(checklistRegex) || []; + + // check if any checklist item is not checked + const unchecked = matches.filter(line => line.startsWith('- [ ]')); + + // if any unchecked, fail the workflow + if (unchecked.length > 0) { + core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`); + } else { + console.log('all checklist items are checked.'); + } + diff --git a/.gitignore b/.gitignore index 668bfcc9..cba47938 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ # config config.yaml .env +.env.* +!.env.example # sqlite *.db @@ -27,8 +29,6 @@ frontend/.next/* frontend/next-env.d.ts frontend/package-lock.json frontend/.env -.env.* -!.env.example *.tsbuildinfo # os @@ -46,24 +46,51 @@ go.work.sum main # upload -uploads/* - +/uploads/ s3_cache + /frontend/.next/ /data/ /internal/router/dist/ /frontend/out/ -/.idea/ -/uploads/ -/*-source/ -/*-source.zip -/.cache/ /internal/router/root/dist/ .dmux/ -.worktrees/ +# test coverage +*.out +coverage.* +*.coverprofile +profile.cov + +# generic ignores +.cache +.gocache* +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*-source +*-source.zip +.codex* +.grok +/.gomodcache/ +*.mmdb +# Server control-plane MaxMind Country seed (Country only; Agent does not embed) +!internal/apps/openflare/geoip/data/GeoLite2-Country.mmdb + /.superpowers/ -/backend/plugins/domain/upload/filesrv/uploads/ -/backend/plugins/domain/upload/task/uploads/ -/backend/data/ -/backend/plugins/drivers/driver_http/dist/ +/.worktrees/ +/.pi-subagents/ + +# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成) +frontend/messages/zh-CN.json +frontend/messages/en.json + +# 上游 vendoring 目录内禁止出现运行期产物 +backend/plugins/**/uploads/ +backend/plugins/**/dist/ +backend/core/**/dist/ +backend/pkg/**/uploads/ +/backend/openflare/plugins/server/upload/filesrv/uploads/ diff --git a/.golangci.yml b/.golangci.yml index adb951c1..613a38e3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -25,17 +25,17 @@ linters: - gocritic # 各类代码问题 - funlen # 函数过长 - - gosec # 安全问题检查 + - gosec # 安全问题检查 - bodyclose # HTTP response body 没有正确关闭 - noctx # 没有传递 context.Context - contextcheck # 其他检查 - - sqlclosecheck # SQL rows 没有正确关闭 - - unconvert # 不必要的类型转换 - - nilerr # 函数返回 nil 错误 + - sqlclosecheck # SQL rows 没有正确关闭 + - unconvert # 不必要的类型转换 + - nilerr # 函数返回 nil 错误 settings: dupl: - threshold: 80 + threshold: 120 cyclop: max-complexity: 20 @@ -53,9 +53,3 @@ linters: - argument - condition - return - - -# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实) -issues: - max-issues-per-linter: 0 - max-same-issues: 0 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c75d9438..2d637411 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,118 +64,186 @@ Strong success criteria let you loop independently. Weak criteria ("make it work **These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. -## Git 提交规范 - -每次完成一个功能点开发或修复一个问题后,务必提交 Git commit , 禁止推送远程仓库。 -遵循 Conventional Commits:`(): `(例:`feat(auth): support email login`)。 - -## 务必阅读匹配的 Skill +## Skills(匹配任务时必读) | Skill | 何时使用 | | :--- | :--- | -| `new-api` | 基于 Cordis 插件开发业务 HTTP API、通过 `ctx.Router()` 声明路由与挂载中间件 | -| `new-async-task` | 基于 Cordis 插件通过 `ctx.Task()` 与 `ctx.Schedule()` 注册 Asynq 异步任务与定时调度 | -| `new-setting` | 基于 Cordis 插件通过 `ctx.Settings()` 声明配置 Schema、绑定 YAML 配置或管理台热加载设置 | -| `database-migration` | 插件自包含 `embed.FS` 独立 Goose SQL 迁移(PG/SQLite 双方言、ClickHouse 分析库) | -| `cache-framework` | 基于 `ctx.Cache()` 与 `contracts.CacheService` 访问三层缓存(RAM L1 + Redis L2 + Pub/Sub 同步) | -| `logstore` | 日志/分析用途表、`internal/repository/logstore`、切换日志主库、PG/SQLite 回落 | -| `clickhouse-batchwriter` | ClickHouse 批量写入、`internal/infra/persistence/batchwriter` 接入、分析表异步 flush 与背压策略 | -| `file-upload` | 业务上传文件、Worker 程序化摄取、`upload.Ingest` / `contracts.StorageService`、文件访问与统计 | -| `push-notification` | 系统通知推送事件、统一触发器投递、带消息推送的业务功能 | -| `release-guide` | 根据自上一正式版本 Tag 以来的提交整理 Version Bump 提交信息以触发双语 Release | -| `shadcn` | 添加、修改或组合 shadcn/ui 组件 | +| `new-api` | 业务 API、Handler、服务层、路由注册 | +| `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 | +| `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` | +| `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed | +| `logstore` | 日志/分析用途表、`backend/internal/repository/logstore`、切换日志主库、PG/SQLite 回落 | +| `clickhouse-batchwriter` | CH 批量写入、batchwriter、分析表 flush/背压 | +| `file-upload` | 上传/摄取、`upload.Ingest`、文件访问、`w_uploads` | +| `cache-framework` | 业务缓存(RAM/Redis/DB)、失效、多节点同步 | +| `push-notification` | 通知推送事件、统一触发器、带推送的业务 | +| `release-guide` | Version Bump 提交信息(触发双语 Release) | +| `shadcn` | 添加/修改/组合 shadcn/ui 组件 | -## 严格遵循事项 (Guardrails) +## 硬性约束 -- 切勿删除 `frontend/node_modules`。 -- 保持 `backend/pkg/util/` 绝对纯净,禁止导入 Gin、GORM、sessions 等 Web/数据库框架包。 -- 测试用例禁止硬编码相对路径创建临时目录,统一使用 Go 内置 `t.TempDir()`。 -- 修改 API Handler 后运行 `make swagger`,完成代码开发后必须依次运行 `make code-check` 与 `make format`。 +### 上游/下游改动归属(Cordis) -### 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/downstream/`)。 - - 每个插件实现 `core.Plugin`(`Name() string` 与 `Apply(ctx *core.Context) error`)。 - - **分层模式选型**: - - **模式 1(极简单文件分层,微型插件)**:单 package 极简结构(仅单文件 `plugin.go`, `handlers.go`, `service.go`, `repository.go`, `models.go`, `errs.go`, `migrations/`)。 - - **模式 2(标准独立子包分层,推荐标准)**:多 package 物理隔离(`plugin.go`, `handler/`, `service/`, `repository/`, `model/`, `errs/`, `migrations/`)。**严禁在根包平铺 `handlers_*`、`service_*`、`repository_*` 等前缀文件**,子包内文件直接按业务命名(如 `user.go`, `config.go`),严格约束 `handler -> service -> repository -> 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("", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins..*`。**严禁**再造全局配置单例或在 `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 保护的连接。 +- 触碰框架目录 `backend/{core,pkg,plugins}` 前,先判断能力归属: + - **通用能力**(与 OpenFlare 业务无关、任何下游都用得上)→ 必须同步在 **Wavelet 上游**完成修改, + 本仓库通过 `git fetch wavelet && git merge wavelet/main` 取得,不得长期持有本地补丁。 + - **非通用能力**(OpenFlare 业务特有)→ 在自己的插件内(`backend/openflare/plugins//`)实现, + 或新建一个下游插件,禁止塞进上游目录。 +- 开发下游功能优先**复用上游已有能力**(`core/contracts`、`backend/plugins/*`、`backend/pkg/*`); + 发现上游已提供而下游仍保留本地副本的,删除本地副本改为复用,或把差量回流上游。 +- 上游暂缺而确属通用能力时,可先在本仓库实现并登记到 `backend/openflare/upstream-patches.md` + (merge 上游后请确认补丁仍在),回流 Wavelet 后删除登记并重新 merge。 -## 后端开发规范 +- 禁止删除 `frontend/node_modules`。 +- `backend/pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `backend/openflare/plugins/server/oauth/session.go`)。 +- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。 +- HTTP 路由只由插件在 `Apply` 中经 `ctx.Router()` 声明;`router.BuildEngine()` 只挂引擎级中间件与前端 SPA 兜底,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。 +- API 变更后:`make swagger`;开发完成:`make code-check`;提交前:`make format`。 +- 缓存/文件管理复用平台实现,业务包禁止自建缓存目录或旁路存储后端。 +- 文件摄取走 `upload.Ingest`(`PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除走 `upload.Remove` / `upload.RemoveOwned`。禁止业务直接 `repository.CreateUpload` / `SoftDeleteUpload` 或 `db.Create(&model.Upload{})`。 +- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。 + - `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。 + - `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。 +- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `backend/openflare/plugins/server/kernel/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。 +- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `backend/openflare/plugins/server/platform/bootstrap` 在 `backend/cmd` 入口显式装配。 +- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `backend/openflare/plugins/server/listener` 发域事件,push 在 bootstrap 订阅。 +- 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。 +- API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。 -### API 响应规范 -- **统一信封**:`{ "error_msg": "", "data": ... }` -- **成功**:HTTP 200,写出 `c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。 -- **失败**:使用 `backend/pkg/response` 的 `Abort*` 系列函数(如 `AbortBadRequest`、`AbortUnauthorized`、`AbortNotFound`、`AbortInternal`)中断请求。 -- **错误文案**:使用模块内 `errs.go` 中的 camelCase 字符串常量(如 `errBindParamsFailed`),禁止暴露底层数据库/系统错误细节给客户端。 -- **Service/Logics 分工**:业务逻辑层只接受 `context.Context`,返回 `(result, error)`,严禁依赖 `*gin.Context` 或调用 `c.JSON`/`Abort*`。 -- **错误日志**:底层错误在 Handler/Logic 边界用 `backend/pkg/logger` 打印日志,禁止使用 `_ = ...` 静默吞掉关键错误。 +### 文档与 Changelog -### 数据库操作 -- 插件数据库表结构严禁使用 GORM AutoMigrate,统一编写 Goose SQL 迁移并嵌入二进制。 -- 不创建物理外键(显式建索引);Go 模型零值需与数据库默认值匹配。 -- **SQL LIKE 查询防注入与转义**:所有含用户输入的模糊查询必须调用 `backend/pkg/util.EscapeLike` 转义通配符,并显式指定 `ESCAPE '\\'` 语法(如 `Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(keyword)+"%")`),同时兼容 PostgreSQL 与 SQLite 方言并杜绝通配符注入攻击。 +- 内容变更同步**中文文档**(不同步英文)。 +- 代码/配置变更写入 [`docs/changelog/index.md`](./docs/changelog/index.md) 的 `[Unreleased]`;纯文档变更不写 changelog。 +- Changelog:合并相近项;不记格式化/调试/无关重构;用户可读完整中文句;说明效果;不编造;不写密钥等敏感信息;空分类可省略。 -### 并发与安全防护规范 -- **Goroutine 安全**:禁止直接使用裸 `go func()`;统一使用 `backend/pkg/util.Go`,确保具备未捕获 panic 恢复和调用栈日志记录能力。 -- **Pub/Sub 监听并发安全**:启动 Redis Pub/Sub 订阅监听前,必须捕获局部客户端实例,禁止在 goroutine 闭包中直读可变全局变量;提供停止监听接口时必须维护 `done` 通道等待 goroutine 完整退出后再重置状态,消除数据竞争。 -- **Session 固定攻击防御**:用户登录/授权成功后,必须调用 Session 轮换逻辑,防止 Session 固定攻击。 -- **防账户枚举与时序攻击**: - - 登录失败统一返回模糊报错;当查询用户不存在时,必须调用 `pkg/util.DummyCheckPassword` 执行同等开销的 bcrypt 哈希计算,彻底消除时序侧信道攻击。 - - 验证码、签名 Token 等敏感字符串比对必须使用 `crypto/subtle.ConstantTimeCompare` 常量时间比对。 -- **敏感端点限流**:登录尝试、OAuth 授权发起等敏感接口必须接入基于 Redis 的滑动窗口限流机制,防止暴力破解与缓存资源耗尽。 +## 技术栈 -## 前端开发规范 +- **后端**:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OTel、Zap、AWS SDK v2、Snowflake IDs +- **前端**:Next.js App Router、TypeScript、Tailwind、pnpm、shadcn/ui -- 新特性开发前参考 Next.js 文档与 `frontend/app/(main)/admin/demo` 示例代码。 -- **页面容器与标题栏**: - - 页面根容器统一使用全宽 `w-full`,最外层统一用 `py-6` 或 `py-6 px-1` 对齐边距。 - - 标题容器统一 `flex items-center gap-2`(带操作按钮用 `justify-between`)。 - - 图标直接使用 Lucide 组件(`size-5 text-primary`),禁止包裹背景小卡片或装饰边框。 - - 标题文字统一使用 `

`。 -- **无障碍语义与色彩规范 (a11y & WCAG)**: - - **标题层级规范 (Heading Hierarchy)**:页面中非顶级结构化标题(如空状态提示、加载提示、卡片眉题/卡片标题、抽屉区块名)严禁滥用 `

`/`

`,统一使用 `

` 配合样式,保证屏幕阅读器感知的标题层级连续。 - - **无文本控件无障碍**:所有仅包含图标的按钮(如仅有 Icon 的 Button、Switch、无文本的 SelectTrigger)必须显式添加 `aria-label`。 - - **色彩对比度**:正文、提示、徽章等小字颜色在亮色/暗色模式下必须满足 WCAG AA(对比度 ≥ 4.5:1)。 -- **组件拆分与维护**: - - 物理路由页面 `page.tsx` 仅维护高级骨架与布局。 - - 单文件超过 600 行或含多 Tab/大复杂区块时,必须按就近原则拆分为子组件存放在路由同级的 `components/` 局部目录中。 -- **样式与服务**: - - 优先使用 shadcn/ui 的 `variant` 和全局 CSS 变量,不要在业务代码中硬编码颜色/背景。 - - 前端请求统一在 `frontend/lib/services//` 中继承 `BaseService` 编写并在 `index.ts` 注册。 -- **国际化 (i18n)**: - - 使用 `next-intl`(**无 URL locale 前缀** / non-routing provider 模式),兼容 `NEXT_STANDALONE_EXPORT` 静态导出。 - - 支持语言:`zh-CN`、`en`;默认 `zh-CN`。 - - 解析优先级:cookie `NEXT_LOCALE`(用户显式选择)→ 浏览器语言 → 默认 `zh-CN`。 - - 文案统一放在 `frontend/messages/{locale}.json`,按命名空间嵌套(`common` / `layout` / `auth` / `settings` / 业务域)。 - - 组件内用户可见文案必须通过 `useTranslations()` / `getTranslations()` 读取;**禁止**新增中英硬编码 UI 字符串(后端返回的 `error_msg`、日志、调试信息除外)。 - - key 使用 camelCase 分层(如 `auth.login.submit`);完整短语作为 value,禁止在组件内拼接句子。 - - 新增或修改文案时必须**同步**更新 `zh-CN.json` 与 `en.json`,保持 key 树一致。 - - 语言选项展示用自称:`中文` / `English`(不随当前 UI 语言翻译)。 - - 日期/数字格式化使用 locale 感知 helper(如 `formatDateTime`),禁止写死 `'zh-CN'` / `date-fns` 的 `zhCN`。 +## Git + +Conventional Commits:`(): `(例:`feat(auth): support email login`)。 + +--- + +## 后端 + +### 命名 + +| 类别 | 规则 | 例 | +|------|------|-----| +| 包/文件 | 小写蛇形 | `auth_source`、`postgres_logger.go` | +| 导出/未导出标识符 | PascalCase / camelCase | — | +| 请求/响应结构体 | camelCase + 后缀 | `listUsersRequest` | +| 错误文案常量 | camelCase 字符串 `const`(非包级 `error`) | `errBindParamsFailed` | +| YAML 键 | 小写蛇形 | — | + +### Handler + +- 命名:动词 + 名词(`ListUsers`);绑定用 `ShouldBindQuery` / `ShouldBindJSON`。 +- 每个 HTTP API 需完整 Swagger 注释;API 变更后 `make swagger`。 +- Handler:绑定 → 调 logic → 映射为 `Abort*` 或 `response.OK`。 +- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `backend/internal/apps/user/logics.go`。 + +### API 响应 + +信封:`{ "error_msg": "", "data": ... }`。成功 `error_msg` 空、`data` 为载荷;失败 `data` 为 `null`。分页:`data: { total, results }`。 + +**成功**(始终 HTTP 200): + +```go +c.JSON(http.StatusOK, response.OK(data)) +c.JSON(http.StatusOK, response.OKNil()) +``` + +**失败**:仅用 `response.Abort*`(挂 `c.Errors` 并 `Abort`,由 `ErrorHandlerMiddleware` 统一写出并记 OTel),阅读/internal/shared/response/abort.go使用已有函数 + +中间件同规则(`oauth.LoginRequired` → Unauthorized;`admin.LoginAdminRequired` → NotFound;`cap.VerifyMiddleware` → Unauthorized)。 + +- 用户可见错误:模块内 `errs.go` 的 camelCase 字符串常量;禁止向客户端暴露驱动错误/堆栈。 +- `response.Err` 仅供中间件构造 JSON,业务禁止用于 `c.JSON`。 + +**禁止**:`c.JSON(200, response.Err(...))`;Handler 直接 `c.JSON(4xx/5xx, response.Err(...))`;手写 `gin.H` 错误体;在 `logics.go` 里 `Abort*`。 + +Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort 状态声明 `@Failure`。 + +### 日志 + +- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `backend/pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。 +- 吞错、转通用响应、worker 忽略前必须先记日志。 +- 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。 +- 只在处理/抑制边界记一次,避免重复刷日志。 + +### 路由与装配 + +- `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。 +- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `backend/internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。 +- API/`all` 模式:`bootstrap.Init` 须在 `RegisterPushDomainEvents()` **之后**调用,保证 `SyncEvents` 同步内置推送元数据。 + +### 中间件 + +- 全局:`gin.Recovery()`、`otelgin`、日志、session。 +- 登录组:`oauth.LoginRequired()`;管理组:`admin.LoginAdminRequired()`。 + +### 配置 + +- 运行时只读 `config.Config`,禁止 `os.Getenv()`。 +- 新增配置同步 `config.example.yaml` 与 `backend/internal/infra/config/model.go`。 + +### 数据库 + +- 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。 +- repository 内用 `db.DB(ctx)`(链路追踪)。 +- 迁移:`backend/internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。 +- 不建物理外键,关系字段加显式索引。 +- 列默认值与 Go 零值(`nil`/`0`/`false`/`""`)一致。 + +--- + +## 前端 + +- Next.js:以 `node_modules/next/dist/docs/` 为准(训练数据可能过时)。 +- 示例:`frontend/app/(main)/admin/demo`。 + +### 样式 + +- shadcn 用 `variant` + CSS 变量;业务 `className` 不硬编码颜色/背景/阴影。 +- 变体不足时扩展组件 variant,不写一次性颜色。 + +### 页面结构 + +- 根容器全宽 `w-full`;禁止页面级 `max-w-*`(主布局负责宽度)。 +- 外层间距:`py-6` 或 `py-6 px-1`。 +- 标题行:`flex items-center gap-2`(有右侧操作则加 `justify-between`)。 +- 图标:Lucide 直接放标题容器,`size-5 text-primary`;禁止背景卡片/边框包裹。 +- 标题:仅 `h1 className="text-2xl font-semibold tracking-tight"`。 +- 多 Tab:各 Tab 独立文件;`page.tsx` 只管 Tabs 状态与触发器;禁止 `page.tsx` 仅转发同名空壳。 +- 单文件 > ~600 行或状态过重时拆局部 `components/`;跨页复用放 `frontend/components/common/`。标杆:`/admin/database`。 + +### 组件放置 + +| 类型 | 路径 | +|------|------| +| 跨页业务 | `frontend/components/common/` | +| shadcn 原语 | `frontend/components/ui/` | +| 路由专属 | 邻近 feature 目录 | + +### Services + +```text +frontend/lib/services// + types.ts + .service.ts + index.ts +``` + +- 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。 +- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。 + +### 国际化 (i18n) + +- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。 +- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。 +- 文案放在 `frontend/messages/fragments`。参考已有代码,按模块拆文件夹,en.json 和 zh-CN.json 是 ci 生成的(node scripts/merge-i18n-fragments.mjs),禁止手动修改。 +- 禁止在页面/组件里直接写文案,文案必须支持 i18 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1307cb39 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ +# 贡献指南 + +感谢您有兴趣为本项目做出贡献!我们欢迎各种形式的贡献,但请先阅读如下文档,以节省您和我们的时间。 + +当您在使用 Claude Code, Gemini CLI 等 Vibe-coding 工具时,推荐将此文档内容附加到上下文内。 + +## 我们不接受的更改 + +出于包括但不限于项目可持续性与可维护性考虑,我们不接受如下类型的更改。 +如果您提交的 PR 包含以下类型的更改,我们可能会包括但不限于忽略、关闭或要求您更改 PR 内容。 + +- 导致项目整体性能下降的更改; +- 仅修改注释、空格、格式的小 PR; +- 仅修正无影响力的拼写错误(typo)或代码注释,不提升可读性或准确性; +- 重构已稳定工作的逻辑而不带来可维护性或功能上的实质提升; +- 未经讨论的接口或 API 命名改动; + +**请注意:判断标准不是改动大小,而是改动是否有实际作用。** + +为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。 + + +## 合并上游 + +`.gitattributes` 对 `backend/openflare/`、`frontend/` 等路径使用 `merge=ours`。该驱动不会自动生效,请在仓库根目录执行一次: + +```bash +git config include.path ../.gitconfig +# worktree 安全写法: +git config include.path "$(git rev-parse --show-toplevel)/.gitconfig" +``` + +## 贡献步骤 + +1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。 +2. **编写代码**,确保遵循项目的代码风格和最佳实践。 +3. **添加/更新测试**,确保您的更改不会破坏现有功能。 +4. **本地测试**,确认所有测试通过。 +5. **提交 Pull Request**,请详细描述您的更改内容和动机。 + + +## 代码规范 + +### 后端 + +**基础检查** + +需要通过 CodeQL 扫描,较长的代码建议增加 Copilot 检查。 + +**API 文档** + +所有接口需要写 Swagger 文档,提交前通过 make swagger 更新文档后再提交。 + +**响应格式** + +```json +# 响应数据最外层有两个字段,error_msg 和 data +{ + "error_msg": "", + "data": null +} + +# 如果是非列表数据 +{ + "error_msg": "", + "data": {} +} + +# 如果是分页数据 +{ + "error_msg": "", + "data": { + "total": 0, + "results": [] + } +} +``` + +**数据库** + +- 禁止使用外键,但需要保留对应字段的索引; +- 字段如有默认值,需要与 struct 默认值相同,如 nil,0,false,空字符串等,避免初始化时未填写或漏填写导致的数据异常。 + +### 前端 + +**基础检查** + +代码需要通过 ESLint 检查和 CodeQL 扫描。 + +**类型安全** + +- 禁止使用 `any` 类型,`any` 类型绕过了 TypeScript 的类型检查系统,会导致潜在的运行时错误; +- `unknown` 是类型安全的 `any`,但必须立即进行类型断言或类型收窄; +- `never` 类型表示永远不会发生的值类型,必须谨慎使用,并提供清晰的注释说明。 + +**组件规范** + +- 组件应按功能分类 +- 公共组件放在 `components/common` 目录 +- ShadcnUI 组件放在 `components/ui` 目录 +- 自定义图标应放置在 `/components/icons/` 目录下以命名导出形式管理,对于常规的图标,我们使用 Lucide 库 + +**服务层** + +服务层架构是前端与API交互的统一入口,基于以下原则: +1. 关注点分离 - 每个服务负责一个业务领域 +2. 统一入口 - 通过services对象导出所有服务 +3. 类型安全 - 所有请求和响应有明确类型定义 + + +**如何新建接口服务** + +1. **创建目录结构**: + ``` + /services/新服务名/ + - types.ts // 类型定义 + - 服务名.service.ts // 服务实现 + - index.ts // 导出服务 + ``` + +2. **实现服务类**: + ```typescript + // 新服务名/服务名.service.ts + import {BaseService} from '../core/base.service'; + + export class 新服务类 extends BaseService { + protected static readonly basePath = '/api/v1/路径'; + + static async 方法名(参数): Promise<返回类型> { + return this.get<返回类型>('/endpoint'); + } + } + ``` + +3. **在services/index.ts注册**: + ```typescript + import {新服务类} from './新服务名'; + + const services = { + auth: AuthService, + 新服务名: 新服务类 + }; + ``` + +**使用方法** + +```typescript +import services from '@/lib/services'; + +// 调用服务方法 +const 结果 = await services.新服务名.方法名(参数); +``` \ No newline at end of file diff --git a/Makefile b/Makefile index a6c323d7..5f35bf6f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: swagger license license-check build-embedded build-test cross-build code-check format canary +.PHONY: swagger license license-check format build-embedded build-test cross-build code-check build-backend build-frontend build-agent build-relay build-flared build-all VERSION ?= dev BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') @@ -14,9 +14,13 @@ license-check: scripts/update_go_license.sh --check format: - @echo "==> Formatting backend Go source with golangci-lint fmt (gofumpt, same gate as code-check)..." - cd backend && golangci-lint fmt - @echo "==> Formatting frontend source..." + @echo "==> Formatting backend Go source and removing unused imports..." + @command -v goimports >/dev/null 2>&1 || { \ + echo "goimports not found, installing..."; \ + go install golang.org/x/tools/cmd/goimports@latest; \ + } + goimports -w $$(find backend -type f -name '*.go') + @echo "==> Formatting frontend source and removing unused imports..." cd frontend && pnpm format build-embedded: @@ -31,21 +35,49 @@ build-embedded: cd backend && go build \ -tags embed_frontend \ -ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \ - -o ../bin/wavelet \ + -o ../bin/openflare-server \ main.go code-check: - @scripts/check_cordis_architecture.sh + @echo "==> Architecture guards..." + @command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; } + @if rg -n 'db\.DB\(|db\.Redis' backend/openflare/plugins/server/kernel/model --glob '*.go' -g '!*_test.go' ; then \ + echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \ + exit 1; \ + fi cd backend && golangci-lint run - cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0 + cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0 build-backend: @echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..." cd backend && go build \ -ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \ - -o ../bin/wavelet \ + -o ../bin/openflare-server \ main.go +build-agent: + @echo "==> Building agent version=$(VERSION)..." + cd backend && go build \ + -ldflags "-s -w -X '$(MODULE)/openflare/plugins/agent/config.Version=$(VERSION)'" \ + -o ../bin/openflare-agent \ + cmd/agent/main.go + +build-relay: + @echo "==> Building relay version=$(VERSION)..." + cd backend && go build \ + -ldflags "-s -w -X '$(MODULE)/openflare/plugins/relay/config.Version=$(VERSION)'" \ + -o ../bin/openflare-relay \ + cmd/relay/main.go + +build-flared: + @echo "==> Building flared version=$(VERSION)..." + cd backend && go build \ + -ldflags "-s -w -X '$(MODULE)/openflare/plugins/flared/config.Version=$(VERSION)'" \ + -o ../bin/flared \ + cmd/flared/main.go + +build-all: build-backend build-agent build-relay build-flared + build-frontend: @echo "==> Building frontend version=$(VERSION) build_date=$(BUILD_DATE)..." cd frontend && \ diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..fd06c425 --- /dev/null +++ b/NOTICE @@ -0,0 +1,9 @@ +OpenFlare + +This product includes software derived from Wavelet. + +Wavelet: +Copyright 2025 Arctel.net +Licensed under the Apache License, Version 2.0. + +This distribution includes modifications by Arctel.net. diff --git a/README.md b/README.md index 26c5bdbf..f540b77c 100644 --- a/README.md +++ b/README.md @@ -1,352 +1,192 @@ -# wavelet +

-🚀 A modern, production-ready full-stack boilerplate for building scalable web applications +# OpenFlare -[中文](./README_zh.md) +**[English](./README.md) | [简体中文](./README.zh-CN.md)** -[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Go Version](https://img.shields.io/badge/Go-1.25+-blue.svg)](https://golang.org/) -[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) -[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) +OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges. -## 📖 Introduction +
-**wavelet** is a generic, production-ready full-stack boilerplate built with **Go (Gin + GORM)** on the backend and **Next.js (App Router + Shadcn UI)** on the frontend. It ships with everything you need to bootstrap a modern SaaS, internal tool, or developer platform — without the boilerplate headaches. +

+ + license + + + release + + + ghcr + +

-The project was designed from the ground up to be **framework-first and business-agnostic**: plug in your own domain logic while reusing the battle-tested infrastructure that comes out of the box. +> [!WARNING] +> After the first login with the `admin` user, you must change the default password `12345678`. +> +> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments. -### ✨ Key Features +## Documentation -- 🔐 **Multi-auth System** — Local password login/registration + pluggable OIDC/OAuth2 providers (supports multiple auth sources simultaneously) -- 🗝️ **Personal Access Tokens** — API key management for programmatic access; supports `Authorization: Bearer` and `X-Access-Token` headers -- 👤 **User Management** — Admin panel for listing, searching, filtering, enabling/disabling user accounts -- ⚙️ **Dynamic System Config** — Key-value system configuration management with live reload, controllable from the admin UI -- 📋 **Async Task Queue** — Background job processing with [Asynq](https://github.com/hibiken/asynq) (Redis-backed), including a scheduling dashboard -- 📁 **S3 File Storage** — Unified file upload/download via S3-compatible APIs with local disk cache -- 📊 **Observability** — Structured logging (Zap) + distributed tracing (OpenTelemetry) -- 🎨 **Modern UI** — Responsive, dark-mode-ready design system built with Tailwind CSS 4 and Shadcn UI -- 📖 **Built-in Documentation** — Integrated docs portal with usage guides, API reference, privacy policy, and terms of service +**https://openflare.fyrn.link** -## 🏗️ Architecture Overview +Common entry points: -``` -┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐ -│ Frontend │ │ Backend │ │ Database │ -│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ -│ │ │ │ │ │ -│ • React 19 │ │ • Gin HTTP Framework │ │ • PostgreSQL │ -│ • TypeScript │ │ • GORM ORM │ │ • Redis Cache │ -│ • Tailwind 4 │ │ • Multi-provider Auth │ │ │ -│ • Shadcn UI │ │ • AccessToken Middleware │ │ │ -│ │ │ • Asynq Task Queue │ │ │ -│ │ │ • OpenTelemetry Tracing │ │ │ -│ │ │ • Swagger API Docs │ │ │ -└─────────────────┘ └─────────────────────────────┘ └─────────────────┘ - │ - ┌──────────┴──────────┐ - │ Multi-Process CLI │ - │ (Cobra + Viper) │ - │ • api (HTTP) │ - │ • worker (Queue) │ - │ • scheduler(Cron) │ - └─────────────────────┘ -``` +* [Quick Start](https://openflare.fyrn.link/guide/quick-start) +* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment) +* [Configuration Reference](https://openflare.fyrn.link/reference/configuration) +* [System Design](https://openflare.fyrn.link/design/) -## 🛠️ Tech Stack +## Core Capabilities -### Backend -- **[Go 1.25+](https://go.dev/doc)** — Primary language -- **[Gin](https://github.com/gin-gonic/gin)** — HTTP web framework -- **[GORM](https://github.com/go-gorm/gorm)** — ORM with PostgreSQL & ClickHouse support -- **[Redis](https://github.com/redis/redis)** — Cache, session store, and task queue backend -- **[Asynq](https://github.com/hibiken/asynq)** — Distributed task queue (Redis-backed) -- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI entrypoint and configuration management -- **[OpenTelemetry](https://opentelemetry.io)** — Distributed tracing and observability -- **[Zap](https://github.com/uber-go/zap)** — Structured, high-performance logging -- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — Auto-generated API documentation -- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3-compatible file storage -- **[Snowflake](https://github.com/bwmarrin/snowflake)** — Distributed ID generation +* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes. +* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients. +* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses. +* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge. +* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy. +* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol. +* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status. +* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login. +* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers. -### Frontend -- **[Next.js 16](https://github.com/vercel/next.js)** — React framework with App Router -- **[React 19](https://github.com/facebook/react)** — UI library -- **[TypeScript](https://github.com/microsoft/TypeScript)** — Type safety -- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — Utility-first styling -- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — Accessible, composable component library -- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — Icon library +## Interface Preview -## 📋 Requirements +### Dashboard Overview -- **Go** >= 1.25 -- **Node.js** >= 18.0 -- **PostgreSQL** >= 14 -- **Redis** >= 6.0 -- **pnpm** >= 8.0 (recommended) +![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png) -## 🚀 Quick Start +### Access Logs -### 1. Clone the Repository +![OpenFlare version release](./docs/assets/readme/domain_overview.png) + +### WAF Protection + +![OpenFlare version release](./docs/assets/readme/waf.png) + +## Quick Start + +### Hardware Configuration Recommendations + +| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes | +|------------------------|-----------------------------------|-----------------------------------|-------| +| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic | +| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing | +| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput | +| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed | + +### 1. Start the Server + +Use `docker-compose`: ```bash -git clone https://github.com/Rain-kl/Wavelet.git refreshing -cd refreshing +# Download environment variable template and create .env file +curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example +cp .env.example .env ``` -### 2. Configure Environment +```yaml +services: + openflare: + image: ghcr.io/rain-kl/openflare:latest + restart: unless-stopped + env_file: .env + environment: + TZ: ${TZ:-Asia/Shanghai} + ports: + - "3000:3000" + volumes: + - openflare_uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-openflare} + POSTGRES_USER: ${DB_USERNAME:-openflare} + POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password} + volumes: + - openflare_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: valkey/valkey:8.0-alpine + restart: unless-stopped + command: ["valkey-server", "--appendonly", "yes"] + volumes: + - openflare_redis_data:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + +volumes: + openflare_uploads: + openflare_postgres_data: + openflare_redis_data: +``` + +See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details. + +Access address: `http://localhost:3000` + +Default account: + +* Username: `admin` +* Password: `12345678` + +### 2. Install Agent + +Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image. + +You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below: + +#### Docker Deployment + +Docker deployment can directly run the Agent image: ```bash -cp config.example.yaml config.yaml +docker pull ghcr.io/rain-kl/openflare-agent:latest +docker rm -f openflare-agent 2>/dev/null || true +docker run -d --name openflare-agent --restart unless-stopped \ + -p 80:80 -p 443:443/tcp -p 443:443/udp \ + -v openflare-agent-pages:/data/var/lib/openflare/pages \ + -e OPENFLARE_SERVER_URL=http://your-server:3000 \ + -e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \ + ghcr.io/rain-kl/openflare-agent:latest ``` -Edit `config.yaml` to configure your database and Redis. OIDC auth sources are configured at runtime in the admin settings page. +## Cordis / Wavelet upstream -### 3. Initialize Database +OpenFlare is built on Wavelet Cordis. After cloning, enable `merge=ours` from `.gitattributes` so `git merge wavelet/main` keeps OpenFlare-owned paths: ```bash -# Start local dependencies (PostgreSQL + Redis) -docker compose up -d - -# Optional: also start ClickHouse -docker compose --profile clickhouse up -d - -# If you use an external PostgreSQL instance instead of Docker, create the database manually -createdb -h -p 5432 -U postgres refreshing - -# Database schema is auto-migrated on first startup +git config include.path ../.gitconfig +# worktree-safe: +git config include.path "$(git rev-parse --show-toplevel)/.gitconfig" ``` -### 4. Start the Backend +`docker compose` uses `docker-compose.yaml`. `docker-compose.wavelet.yml` is the upstream Wavelet stack and is not the product default. Image publishes go through `.github/workflows/build-image-openflare*.yml`; the Wavelet `build-image.yml` is isolated. -```bash -# Install Go dependencies -go mod tidy +## Open Source License -# Generate Swagger API documentation -make swagger +This project is licensed under the [Apache License 2.0](./LICENSE). -# Start the HTTP API server -go run main.go api -``` +## Star History -> The backend also supports separate `scheduler` and `worker` processes for async task processing: -> ```bash -> go run main.go scheduler # Cron job scheduler -> go run main.go worker # Asynq task worker -> ``` - -### 5. Start the Frontend - -```bash -cd frontend - -# Install dependencies -pnpm install - -# Start dev server (Turbopack) -pnpm dev -``` - -### 6. Access the Application - -| Service | URL | -|---------|-----| -| Frontend | http://localhost:3000 | -| Swagger API Docs | http://localhost:8000/swagger/index.html | -| Health Check | http://localhost:8000/api/health | - -## ⚙️ Configuration - -Key configuration options (see `config.example.yaml` for the full reference): - -| Option | Description | Example | -|--------|-------------|---------| -| `app.addr` | Backend listen address | `:8000` | -| `database.host` | PostgreSQL host | `127.0.0.1` | -| `database.database` | Database name | `refreshing` | -| `redis.host` | Redis host | `127.0.0.1` | -| `storage.endpoint` | S3-compatible endpoint | `s3.amazonaws.com` | - -## 🔧 Development Guide - -### Backend - -```bash -# Run API server -go run main.go api - -# Run task scheduler -go run main.go scheduler - -# Run async worker -go run main.go worker - -# Regenerate Swagger docs (required after controller changes) -make swagger - -# Format & vet code -make tidy -``` - -### Frontend - -```bash -cd frontend - -# Development mode (Turbopack) -pnpm dev - -# Production build -pnpm build - -# Start production server -pnpm start - -# Lint & format -pnpm lint -pnpm format -``` - -## 📁 Project Structure - -``` -wavelet/ -├── main.go # Entry point (delegates to internal/cmd) -├── config.example.yaml # Configuration template -├── Makefile # Common commands (swagger, tidy, license, cross-build) -├── docker/ # Docker image build files (integrated/frontend/backend) -├── docs/ # Swagger auto-generated docs -├── frontend/ # Next.js frontend application -│ ├── app/ # App Router pages -│ ├── components/ # React components (ui, common, layout) -│ ├── lib/services/ # API service layer -│ └── types/ # TypeScript type definitions -└── internal/ # Go backend (private) - ├── cmd/ # CLI commands (api, scheduler, worker) - ├── apps/ # Business modules (oauth, user, admin, upload) - ├── model/ # GORM entities and business methods - ├── router/ # HTTP route registration - ├── task/ # Async task definitions and workers - ├── db/ # Database and Redis initialization - ├── storage/ # S3 file storage abstraction - └── common/ # Shared utilities and response helpers -``` - -## 📚 API Documentation - -Swagger API documentation is auto-generated and available once the backend is running: - -``` -http://localhost:8000/swagger/index.html -``` - -The built-in frontend docs portal at `/docs` includes: -- **Usage Guide** — Step-by-step walkthrough for getting started -- **API Reference** — Detailed interface documentation -- **Privacy Policy** — Template privacy policy (customize as needed) -- **Terms of Service** — Template terms of service - -## 🧪 Testing - -```bash -# Backend tests -go test ./... - -# Frontend lint -cd frontend && pnpm lint -``` - -## 🚀 Deployment - -### Cross-platform Binary - -Build static binaries for all 6 targets (Linux / macOS / Windows × amd64 / arm64) with a single command. -The compiled frontend is embedded in every binary — no separate deployment needed. - -**Prerequisites:** Docker with BuildKit enabled (Docker 23+ defaults to on). - -```bash -# Build all 6 binaries → ./bin/ -make cross-build - -# Stamp a release version -make cross-build VERSION=v1.2.3 - -# Build only a specific OS (both architectures) -make cross-build GOOS=linux -make cross-build GOOS=darwin -make cross-build GOOS=windows - -# Build only a specific architecture (all OSes) -make cross-build GOARCH=amd64 -make cross-build GOARCH=arm64 - -# Combine filters — single binary -make cross-build GOOS=linux GOARCH=arm64 -make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3 -``` - -Output files in `./bin/`: - -| File | Platform | -|------|----------| -| `wavelet_linux_amd64` | Linux x86-64 | -| `wavelet_linux_arm64` | Linux ARM64 | -| `wavelet_darwin_amd64` | macOS Intel | -| `wavelet_darwin_arm64` | macOS Apple Silicon | -| `wavelet_windows_amd64.exe` | Windows x86-64 | -| `wavelet_windows_arm64.exe` | Windows ARM64 | - -> The version string is accessible at runtime via `wavelet --version`. - -### Docker - -```bash -# Build image -docker build -t refreshing . - -# Run (pass your config as a volume mount) -docker run -d -p 8000:8000 \ - -v $(pwd)/config.yaml:/app/config.yaml \ - refreshing api -``` - -### Production - -1. Build the frontend: - ```bash - cd frontend && pnpm build - ``` - -2. Compile the backend: - ```bash - go build -o refreshing main.go - ``` - -3. Configure `config.yaml` for production. - -4. Start services: - ```bash - ./refreshing api # HTTP API - ./refreshing scheduler # Cron scheduler (optional) - ./refreshing worker # Task worker (optional) - ``` - -## 🤝 Contributing - -We welcome contributions! Please read the following before submitting code: - -- [Contributing Guidelines](CONTRIBUTING.md) -- [Code of Conduct](CODE_OF_CONDUCT.md) -- [Contributor License Agreement](CLA.md) - -### Workflow - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/your-feature`) -3. Commit your changes (`git commit -am 'Add your feature'`) -4. Push to the branch (`git push origin feature/your-feature`) -5. Open a Pull Request - -## 📄 License - -This project is licensed under the [Apache 2.0 License](LICENSE). + + + + + Star History Chart + + diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..d9207664 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,192 @@ +
+ +# OpenFlare + +**[English](./README.md) | [简体中文](./README.zh-CN.md)** + +OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。 + +
+ +

+ + license + + + release + + + ghcr + +

+ +> [!WARNING] +> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。 +> +> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。 + +## 文档 + +**https://openflare.fyrn.link** + +常用入口: + +* [快速开始](https://openflare.fyrn.link/guide/quick-start) +* [部署说明](https://openflare.fyrn.link/deployment/deployment) +* [配置项参考](https://openflare.fyrn.link/reference/configuration) +* [系统设计](https://openflare.fyrn.link/design/) + +## 核心能力 + +* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。 +* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。 +* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。 +* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。 +* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。 +* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。 +* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。 +* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。 +* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。 + +## 界面预览 + +### 仪表盘总览 + +![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png) + +### 访问日志 + +![OpenFlare version release](./docs/assets/readme/domain_overview.png) + +### WAF 防护 + +![OpenFlare version release](./docs/assets/readme/waf.png) + +## 快速开始 + +### 硬件配置推荐 + +| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 | +| --- |-------------------------------| --- | --- | +| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 | +| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 | +| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 | +| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 | + +### 1. 启动 Server + +使用 docker-compose + +```bash +# 下载环境变量模板并创建 .env 文件 +curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example +cp .env.example .env +``` + +```yaml +services: + openflare: + image: ghcr.io/rain-kl/openflare:latest + restart: unless-stopped + env_file: .env + environment: + TZ: ${TZ:-Asia/Shanghai} + ports: + - "3000:3000" + volumes: + - openflare_uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-openflare} + POSTGRES_USER: ${DB_USERNAME:-openflare} + POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password} + volumes: + - openflare_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: valkey/valkey:8.0-alpine + restart: unless-stopped + command: ["valkey-server", "--appendonly", "yes"] + volumes: + - openflare_redis_data:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + +volumes: + openflare_uploads: + openflare_postgres_data: + openflare_redis_data: +``` + +详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。 + +访问地址:`http://localhost:3000` + +默认账号: + +* 用户名:`admin` +* 密码:`12345678` + +### 2. 安装 Agent + +安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。 + +你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本: + +#### Docker 部署 + +Docker 部署可直接运行 Agent 镜像: + +```bash +docker pull ghcr.io/rain-kl/openflare-agent:latest +docker rm -f openflare-agent 2>/dev/null || true +docker run -d --name openflare-agent --restart unless-stopped \ + -p 80:80 -p 443:443/tcp -p 443:443/udp \ + -v openflare-agent-pages:/data/var/lib/openflare/pages \ + -e OPENFLARE_SERVER_URL=http://your-server:3000 \ + -e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \ + ghcr.io/rain-kl/openflare-agent:latest +``` + +## Cordis / Wavelet 上游 + +OpenFlare 构建在 Wavelet Cordis 之上。克隆后请启用 `.gitattributes` 中的 `merge=ours`,这样 `git merge wavelet/main` 会保留 OpenFlare 自有路径: + +```bash +git config include.path ../.gitconfig +# worktree 安全写法: +git config include.path "$(git rev-parse --show-toplevel)/.gitconfig" +``` + +`docker compose` 使用 `docker-compose.yaml`。`docker-compose.wavelet.yml` 是上游 Wavelet 编排,不是本产品的默认栈。镜像发布走 `.github/workflows/build-image-openflare*.yml`;Wavelet 的 `build-image.yml` 已隔离。 + +## 开源协议 + +本项目采用 [Apache License 2.0](./LICENSE) 开源。 + +## Star History + + + + + + Star History Chart + + diff --git a/README_zh.md b/README_zh.md index 686af6b6..0ce1e87c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -152,7 +152,7 @@ pnpm dev |------|------| | 前端界面 | http://localhost:3000 | | Swagger 接口文档 | http://localhost:8000/swagger/index.html | -| 健康检查 | http://localhost:8000/api/health | +| 健康检查 | http://localhost:8000/api/healthz | ## ⚙️ 配置说明 diff --git a/backend/cmd/agent/main.go b/backend/cmd/agent/main.go new file mode 100644 index 00000000..7565377c --- /dev/null +++ b/backend/cmd/agent/main.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Command agent runs the OpenFlare edge agent daemon. +package main + +import ( + "flag" + "log/slog" + "os" + "time" + + "Wavelet/core" + agentplugin "Wavelet/openflare/plugins/agent" + "Wavelet/openflare/plugins/agent/logging" +) + +// shutdownTimeout 为 openresty 收敛与在途配置同步预留的退出窗口。 +const shutdownTimeout = 60 * time.Second + +func main() { + logging.Setup() + + configPath := flag.String("config", "./agent.json", "agent config path") + flag.Parse() + + app := core.NewApp( + core.WithProfile(core.Profile(agentplugin.DriverTypeAgent)), + core.WithShutdownTimeout(shutdownTimeout), + ) + app.Use(agentplugin.New(*configPath)) + + if err := app.Prepare(); err != nil { + slog.Error("agent startup failed", "error", err) + os.Exit(1) + } + if err := app.Run(); err != nil { + slog.Error("agent process exited with error", "error", err) + os.Exit(1) + } +} diff --git a/backend/cmd/app.go b/backend/cmd/app.go index 32fde466..482bb031 100644 --- a/backend/cmd/app.go +++ b/backend/cmd/app.go @@ -6,6 +6,8 @@ package cmd import ( "Wavelet/core" "Wavelet/core/contracts" + ofserver "Wavelet/openflare/plugins/server" + "Wavelet/openflare/plugins/server/migrate" "Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/cap" @@ -49,7 +51,7 @@ const ( // runProfileApp prepares and runs the application for a given profile. func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) { - app := newWaveletApp(profile) + app := newOpenFlareApp(profile) if err := app.Prepare(); err != nil { log.Fatalf("[%s] prepare failed: %v\n", mode, err) } @@ -67,8 +69,8 @@ func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) { } } -// newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers. -func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App { +// newOpenFlareApp creates a core.App wired with Wavelet platform plugins plus the OpenFlare server plugin. +func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App { src, err := config.NewSource() if err != nil { log.Fatalf("[App] load config source failed: %v\n", err) @@ -78,6 +80,7 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App { core.WithProfile(profile), core.WithConfigSource(src), core.WithShutdownTimeout(defaultShutdownTimeout), + core.WithMigrationBaseline(migrate.Legacy), } appOpts = append(appOpts, opts...) @@ -112,10 +115,15 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App { system.New(), ) - // 4. Bind Goose migration engine + // 4. OpenFlare business routes (after domain plugins, before the HTTP driver) + app.Use( + ofserver.New(), + ) + + // 5. Bind Goose migration engine app.SetMigrationEngine(&gooseEngine{}) - // 5. Mount HTTP runtime driver + // 6. Mount HTTP runtime driver app.Use( driver_http.New(), ) diff --git a/backend/cmd/app_test.go b/backend/cmd/app_test.go index 2376585f..23ab2278 100644 --- a/backend/cmd/app_test.go +++ b/backend/cmd/app_test.go @@ -4,148 +4,130 @@ package cmd import ( - "Wavelet/core" - "context" + "path/filepath" "testing" - "github.com/alicebob/miniredis/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "Wavelet/core" ) -func TestNewWaveletAppProfiles(t *testing.T) { - profiles := []core.Profile{ - core.ProfileAPI, - core.ProfileWorker, - core.ProfileSchedule, - core.ProfileAll, - } - - for _, prof := range profiles { - t.Run(string(prof), func(t *testing.T) { - app := newWaveletApp(prof, core.WithConfigValues(map[string]any{ - "app": map[string]any{ - "addr": "127.0.0.1:0", - }, - "redis": map[string]any{ - "enabled": false, - }, - })) - require.NotNil(t, app) - assert.Equal(t, prof, app.Profile()) - - // 3 infra + 2 cache + 4 worker/cron + 8 domain + 1 http driver = 18 plugins - plugins := app.Plugins() - assert.Len(t, plugins, 18) - - require.NoError(t, app.Reconcile()) - - // Verify standard infra plugins - _, ok := app.Plugin("database") - assert.True(t, ok, "database plugin missing") - - _, ok = app.Plugin("logger") - assert.True(t, ok, "logger plugin missing") - - _, ok = app.Plugin("storage") - assert.True(t, ok, "storage plugin missing") - - // In zero-Redis mode (default in test) - f, ok := app.Fiber("cache_memory") - assert.True(t, ok, "cache_memory fiber missing") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("cache") - assert.True(t, ok, "cache fiber missing") - assert.Equal(t, core.FiberSkipped, f.State()) - - f, ok = app.Fiber("driver_inproc_worker") - assert.True(t, ok, "inproc worker driver fiber missing") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("driver_asynq_worker") - assert.True(t, ok, "asynq worker driver fiber missing") - assert.Equal(t, core.FiberSkipped, f.State()) - - f, ok = app.Fiber("driver_inproc_cron") - assert.True(t, ok, "inproc scheduler driver fiber missing") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("driver_asynq_cron") - assert.True(t, ok, "asynq scheduler driver fiber missing") - assert.Equal(t, core.FiberSkipped, f.State()) - - // Verify domain plugins - _, ok = app.Plugin("auth") - assert.True(t, ok, "auth plugin missing") - - _, ok = app.Plugin("user") - assert.True(t, ok, "user plugin missing") - - _, ok = app.Plugin("message_gateway") - assert.True(t, ok, "message_gateway plugin missing") - - _, ok = app.Plugin("risk_control") - assert.True(t, ok, "risk_control plugin missing") - - _, ok = app.Plugin("admin") - assert.True(t, ok, "admin plugin missing") - - _, ok = app.Plugin("upload") - assert.True(t, ok, "upload plugin missing") - - _, ok = app.Plugin("cap") - assert.True(t, ok, "cap plugin missing") - - _, ok = app.Plugin("system") - assert.True(t, ok, "system plugin missing") - - // Verify driver plugins - _, ok = app.Plugin("driver_http") - assert.True(t, ok, "http driver missing") - }) - } -} - -func TestNewWaveletAppWithRedisEnabled(t *testing.T) { - mr, err := miniredis.Run() - require.NoError(t, err) - defer mr.Close() - - app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ - "redis": map[string]any{ - "enabled": true, - "addrs": []string{mr.Addr()}, +func testSource(t *testing.T) core.ConfigSource { + t.Helper() + return core.NewMapSource(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + "env": "testing", }, - })) - require.NotNil(t, app) - defer func() { - _ = app.Stop(context.Background()) - _ = app.Context().Dispose() - }() - require.NoError(t, app.Reconcile()) - - f, ok := app.Fiber("cache") - assert.True(t, ok, "cache plugin missing in Redis mode") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("driver_asynq_worker") - assert.True(t, ok, "asynq worker driver missing in Redis mode") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("driver_asynq_cron") - assert.True(t, ok, "asynq scheduler driver missing in Redis mode") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("cache_memory") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State()) - - f, ok = app.Fiber("driver_inproc_worker") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State()) - - f, ok = app.Fiber("driver_inproc_cron") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State()) + "redis": map[string]any{ + "enabled": false, + }, + "database": map[string]any{ + "enabled": false, + "sqlite_path": filepath.Join(t.TempDir(), "openflare-cmd.db"), + }, + }) +} + +func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) { + app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t))) + if err := app.Prepare(); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, p := range app.Plugins() { + names[p.Name()] = true + } + for _, n := range []string{"user", "auth", "cap", "admin", "server"} { + if !names[n] { + t.Errorf("missing plugin %s", n) + } + } + + if err := app.Reconcile(); err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + for _, rd := range app.Context().Router().Routes() { + got[rd.Method+" "+rd.Path] = true + } + for _, want := range []string{ + "GET /api/healthz", + "GET /api/v1/user/self", + "GET /api/v1/d/nodes", + "POST /api/v1/cap/challenge", + } { + if !got[want] { + t.Errorf("missing route %s", want) + } + } + for _, drop := range []string{ + "GET /api/health", + "GET /healthz", + "POST /api/cap/challenge", + } { + if got[drop] { + t.Errorf("removed route still registered: %s", drop) + } + } +} + +func TestFreshInstallSeedsOpenFlareDefaults(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "fresh.db") + app := cordisPrepare(t, cordisSQLiteSource(t, dbPath)) + t.Cleanup(func() { _ = app.Context().Dispose() }) + + db := openInspectDB(t, dbPath, "") + defer func() { _ = db.Close() }() + + var tables int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%'`).Scan(&tables); err != nil { + t.Fatal(err) + } + if tables == 0 { + t.Fatal("fresh install created no of_* tables") + } + + rows, err := db.Query(`SELECT task_type FROM w_schedules WHERE task_type LIKE 'of_%' ORDER BY 1`) + if err != nil { + t.Fatal(err) + } + defer func() { _ = rows.Close() }() + var got []string + for rows.Next() { + var taskType string + if err := rows.Scan(&taskType); err != nil { + t.Fatal(err) + } + got = append(got, taskType) + } + want := []string{ + "of_pages_source_scan", + "of_ssl_renew", + "of_uptime_kuma_sync", + "of_waf_ip_group_sync", + } + if len(got) != len(want) { + t.Fatalf("of_* schedules = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("of_* schedules = %v, want %v", got, want) + } + } + + var cleanup int + if err := db.QueryRow(`SELECT COUNT(*) FROM w_schedules WHERE task_type = 'of_database_auto_cleanup'`).Scan(&cleanup); err != nil { + t.Fatal(err) + } + if cleanup != 0 { + t.Fatal("must not seed of_database_auto_cleanup") + } + + var geoip string + if err := db.QueryRow(`SELECT value FROM w_system_configs WHERE key = 'geoip_provider'`).Scan(&geoip); err != nil { + t.Fatalf("geoip_provider: %v", err) + } + if geoip != "ipinfo" { + t.Fatalf("geoip_provider = %q, want ipinfo", geoip) + } } diff --git a/backend/cmd/banner.go b/backend/cmd/banner.go index 882d2a06..229d8185 100644 --- a/backend/cmd/banner.go +++ b/backend/cmd/banner.go @@ -33,12 +33,13 @@ func formatStartupBanner(state startupState) string { lines := []string{ "", - "__ __ _ _ ", - "\\ \\ / /_ ___ _____ | | ___| |_ ", - " \\ \\ /\\ / / _` \\ \\ / / _ \\ | |/ _ \\ __|", - " \\ V V / (_| |\\ V / __/ | | __/ |_ ", - " \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|", - fmt.Sprintf(" Wavelet %s", buildinfo.Version), + " ____ ________ ", + " / __ \\____ ___ ____ / ____/ /___ _________ ", + " / / / / __ \\/ _ \\/ __ \\/ /_ / / __ `/ ___/ _ \\", + "/ /_/ / /_/ / __/ / / / __/ / / /_/ / / / __/", + "\\____/ .___/\\___/_/ /_/_/ /_/\\__,_/_/ \\___/ ", + " /_/ ", + fmt.Sprintf(" OpenFlare %s", buildinfo.Version), "", fmt.Sprintf(" Environment: %s", env), fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()), diff --git a/backend/cmd/banner_test.go b/backend/cmd/banner_test.go index 75f5ea9e..83ab12ee 100644 --- a/backend/cmd/banner_test.go +++ b/backend/cmd/banner_test.go @@ -28,7 +28,7 @@ func TestFormatStartupBanner(t *testing.T) { }) for _, want := range []string{ - "Wavelet v3.2.1", + "OpenFlare v3.2.1", "Environment: production", "Build time: 2026-07-13T08:00:00Z", "Listening: http://:3000", diff --git a/backend/cmd/flared/main.go b/backend/cmd/flared/main.go new file mode 100644 index 00000000..d2c4b44b --- /dev/null +++ b/backend/cmd/flared/main.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Command flared runs the OpenFlare tunnel client daemon. +package main + +import ( + "flag" + "log/slog" + "os" + "time" + + "Wavelet/core" + flaredplugin "Wavelet/openflare/plugins/flared" + edgelogging "Wavelet/openflare/share/edge/logging" +) + +// shutdownTimeout 为 frpc 子进程收敛预留的退出窗口。 +const shutdownTimeout = 60 * time.Second + +func main() { + edgelogging.Setup(edgelogging.Options{}) + + configPath := flag.String("config", "./flared.json", "flared config path") + flag.Parse() + + app := core.NewApp( + core.WithProfile(core.Profile(flaredplugin.DriverTypeFlared)), + core.WithShutdownTimeout(shutdownTimeout), + ) + app.Use(flaredplugin.New(*configPath)) + + if err := app.Prepare(); err != nil { + slog.Error("flared startup failed", "error", err) + os.Exit(1) + } + if err := app.Run(); err != nil { + slog.Error("flared process exited with error", "error", err) + os.Exit(1) + } +} diff --git a/backend/cmd/parity_test.go b/backend/cmd/parity_test.go new file mode 100644 index 00000000..eeaef019 --- /dev/null +++ b/backend/cmd/parity_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "Wavelet/core" +) + +// baselineRoutesFile 是改造前遗留注册路径导出的 (方法 路径) 全集。 +const baselineRoutesFile = "docs/superpowers/specs/baseline/routes-engine.txt" + +func TestPluginRoutesContainGoldenBaseline(t *testing.T) { + app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t))) + if err := app.Prepare(); err != nil { + t.Fatal(err) + } + if err := app.Reconcile(); err != nil { + t.Fatal(err) + } + + got := routeSet(app.Context()) + want := loadBaseline(t) + for _, drop := range []string{ + "GET /api/health", + "GET /healthz", + "POST /api/cap/challenge", + "POST /api/cap/redeem", + } { + delete(want, drop) + } + for k := range want { + if !got[k] { + t.Errorf("missing golden route %s", k) + } + } + for _, must := range []string{ + "GET /api/healthz", + "POST /api/v1/cap/challenge", + "POST /api/v1/cap/redeem", + } { + if !got[must] { + t.Errorf("missing required route %s", must) + } + } + for _, drop := range []string{ + "GET /api/health", + "GET /healthz", + "POST /api/cap/challenge", + "POST /api/cap/redeem", + } { + if got[drop] { + t.Errorf("removed route still registered: %s", drop) + } + } +} + +func routeSet(ctx *core.Context) map[string]bool { + set := make(map[string]bool) + for _, rd := range ctx.Router().Routes() { + set[rd.Method+" "+rd.Path] = true + } + return set +} + +func loadBaseline(t *testing.T) map[string]bool { + t.Helper() + path := locateFile(t, baselineRoutesFile) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read baseline %s: %v", path, err) + } + set := make(map[string]bool) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line != "" { + set[line] = true + } + } + if len(set) == 0 { + t.Fatalf("baseline %s is empty", path) + } + return set +} + +func locateFile(t *testing.T, rel string) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(thisFile) + for range 8 { + candidate := filepath.Join(dir, rel) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + dir = filepath.Join(dir, "..") + } + t.Fatalf("%s not found above %s", rel, filepath.Dir(thisFile)) + return "" +} diff --git a/backend/cmd/redis_plug_test.go b/backend/cmd/redis_plug_test.go deleted file mode 100644 index f717bdb9..00000000 --- a/backend/cmd/redis_plug_test.go +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "Wavelet/core" - "Wavelet/core/contracts" - "Wavelet/core/extpoints" - "Wavelet/pkg/idgen" - "context" - "fmt" - "sync/atomic" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRedisPluggability_Simulation(t *testing.T) { - _ = idgen.Init(1) - - // ══════════════════════════════════════════════════════════════════════════ - // 场景 1: 拔出 Redis (Zero-Redis Monolith 模式) - // ══════════════════════════════════════════════════════════════════════════ - t.Run("Scenario_Unplugged_ZeroRedis_Mode", func(t *testing.T) { - app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ - "app": map[string]any{ - "addr": "127.0.0.1:0", - }, - "redis": map[string]any{ - "enabled": false, - }, - })) - require.NotNil(t, app) - require.NoError(t, app.Reconcile()) - - // 1. 验证插件挂载形态 - f, ok := app.Fiber("cache_memory") - assert.True(t, ok, "cache_memory 必须挂载") - assert.Equal(t, core.FiberActive, f.State()) - f, ok = app.Fiber("driver_inproc_worker") - assert.True(t, ok, "driver_inproc_worker 必须挂载") - assert.Equal(t, core.FiberActive, f.State()) - f, ok = app.Fiber("driver_inproc_cron") - assert.True(t, ok, "driver_inproc_cron 必须挂载") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("cache") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State(), "分布式 cache 不得挂载") - f, ok = app.Fiber("driver_asynq_worker") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State(), "asynq_worker 不得挂载") - f, ok = app.Fiber("driver_asynq_cron") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State(), "asynq_cron 不得挂载") - - // 2. 注册测试任务与 Cron 定时 - var taskExecuted atomic.Int32 - var cronExecuted atomic.Int32 - - app.Context().Tasks().Register("test:inproc_task", func(ctx context.Context, payload []byte) error { - if string(payload) == "payload_unplugged" { - taskExecuted.Add(1) - } - return nil - }, extpoints.WithTaskTimeout(3*time.Second)) - - app.Context().Schedules().RegisterCron("* * * * * *", "test:inproc_cron", []byte("cron_ping")) - app.Context().Tasks().Register("test:inproc_cron", func(ctx context.Context, payload []byte) error { - if string(payload) == "cron_ping" { - cronExecuted.Add(1) - } - return nil - }) - - // 3. 启动应用 - bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer bootCancel() - require.NoError(t, app.Start(bootCtx)) - - // 4. 验证 CacheService 操作 - cacheSvc, err := core.Inject[contracts.CacheService](app.Context()) - require.NoError(t, err) - require.NotNil(t, cacheSvc) - - reqCtx := context.Background() - require.NoError(t, cacheSvc.Set(reqCtx, "unplugged_key", "value_123", time.Minute)) - var val string - require.NoError(t, cacheSvc.Get(reqCtx, "unplugged_key", &val)) - assert.Equal(t, "value_123", val) - - // 5. 验证异步 Worker 任务分发与执行 - taskSvc, err := core.Inject[contracts.TaskService](app.Context()) - require.NoError(t, err) - require.NotNil(t, taskSvc) - - taskID, err := taskSvc.Dispatch(reqCtx, "test:inproc_task", []byte("payload_unplugged"), "unit_test") - require.NoError(t, err) - assert.NotEmpty(t, taskID) - - require.Eventually(t, func() bool { - return taskExecuted.Load() >= 1 - }, 3*time.Second, 50*time.Millisecond, "内存 Worker 应在进程内顺利执行任务") - - // 6. 验证 Cron 定时触发 - require.Eventually(t, func() bool { - return cronExecuted.Load() >= 1 - }, 3*time.Second, 100*time.Millisecond, "内存 Cron 驱动应成功触发定时任务") - - // 7. 优雅关闭 - stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer stopCancel() - require.NoError(t, app.Stop(stopCtx)) - }) - - // ══════════════════════════════════════════════════════════════════════════ - // 场景 2: 插入 Redis (Distributed Cluster 模式) - // ══════════════════════════════════════════════════════════════════════════ - t.Run("Scenario_Plugged_Redis_Mode", func(t *testing.T) { - mr, err := miniredis.Run() - require.NoError(t, err) - defer mr.Close() - - app := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ - "app": map[string]any{ - "addr": "127.0.0.1:0", - }, - "redis": map[string]any{ - "enabled": true, - "addrs": []string{mr.Addr()}, - }, - })) - require.NotNil(t, app) - require.NoError(t, app.Reconcile()) - - // 1. 验证插件挂载形态 - f, ok := app.Fiber("cache") - assert.True(t, ok, "分布式 cache 必须挂载") - assert.Equal(t, core.FiberActive, f.State()) - f, ok = app.Fiber("driver_asynq_worker") - assert.True(t, ok, "driver_asynq_worker 必须挂载") - assert.Equal(t, core.FiberActive, f.State()) - f, ok = app.Fiber("driver_asynq_cron") - assert.True(t, ok, "driver_asynq_cron 必须挂载") - assert.Equal(t, core.FiberActive, f.State()) - - f, ok = app.Fiber("cache_memory") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State(), "纯内存 cache 不得挂载") - f, ok = app.Fiber("driver_inproc_worker") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State(), "inproc_worker 不得挂载") - f, ok = app.Fiber("driver_inproc_cron") - assert.True(t, ok) - assert.Equal(t, core.FiberSkipped, f.State(), "inproc_cron 不得挂载") - - // 2. 注册测试任务 - var asynqTaskExecuted atomic.Int32 - app.Context().Tasks().Register("test:asynq_task", func(ctx context.Context, payload []byte) error { - if string(payload) == "payload_plugged" { - asynqTaskExecuted.Add(1) - } - return nil - }, extpoints.WithTaskTimeout(3*time.Second)) - - // 3. 启动应用 - bootCtx, bootCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer bootCancel() - require.NoError(t, app.Start(bootCtx)) - - // 4. 验证 CacheService 操作 (L1 RAM + L2 Redis) - cacheSvc, err := core.Inject[contracts.CacheService](app.Context()) - require.NoError(t, err) - require.NotNil(t, cacheSvc) - - reqCtx := context.Background() - testKey := fmt.Sprintf("plugged_key_%d", time.Now().UnixNano()) - require.NoError(t, cacheSvc.Set(reqCtx, testKey, "value_redis_cluster", time.Minute)) - - var val string - require.NoError(t, cacheSvc.Get(reqCtx, testKey, &val)) - assert.Equal(t, "value_redis_cluster", val) - - // 验证失效广播与删除 - require.NoError(t, cacheSvc.Delete(reqCtx, testKey)) - var valAfterDelete string - err = cacheSvc.Get(reqCtx, testKey, &valAfterDelete) - assert.ErrorIs(t, err, contracts.ErrCacheMiss) - - // 5. 验证 Asynq Worker 任务分发与消费 - taskSvc, err := core.Inject[contracts.TaskService](app.Context()) - require.NoError(t, err) - require.NotNil(t, taskSvc) - - taskID, err := taskSvc.Dispatch(reqCtx, "test:asynq_task", []byte("payload_plugged"), "default") - require.NoError(t, err) - assert.NotEmpty(t, taskID) - - require.Eventually(t, func() bool { - return asynqTaskExecuted.Load() >= 1 - }, 10*time.Second, 100*time.Millisecond, "Asynq Worker 应从 Redis 队列中成功消费并执行任务") - - // 6. 优雅关闭 - stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer stopCancel() - require.NoError(t, app.Stop(stopCtx)) - _ = app.Context().Dispose() - }) - - // ══════════════════════════════════════════════════════════════════════════ - // 场景 3: 往复插拔连续切换 (拔出 → 插入 → 再拔出,验证时空可组合性与零残留) - // ══════════════════════════════════════════════════════════════════════════ - t.Run("Scenario_Dynamic_Plug_Unplug_Sequence", func(t *testing.T) { - mr, err := miniredis.Run() - require.NoError(t, err) - defer mr.Close() - - for i := 1; i <= 2; i++ { - // 1. 拔出 Redis 运行 - appUnplugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ - "app": map[string]any{ - "addr": "127.0.0.1:0", - }, - "redis": map[string]any{ - "enabled": false, - }, - })) - require.NoError(t, appUnplugged.Start(context.Background())) - - cacheSvc1, err := core.Inject[contracts.CacheService](appUnplugged.Context()) - require.NoError(t, err) - require.NoError(t, cacheSvc1.Set(context.Background(), fmt.Sprintf("seq_key_%d", i), "seq_val_unplugged", time.Minute)) - - require.NoError(t, appUnplugged.Stop(context.Background())) - _ = appUnplugged.Context().Dispose() - - // 2. 插入 Redis 运行 - appPlugged := newWaveletApp(core.ProfileAll, core.WithConfigValues(map[string]any{ - "app": map[string]any{ - "addr": "127.0.0.1:0", - }, - "redis": map[string]any{ - "enabled": true, - "addrs": []string{mr.Addr()}, - }, - })) - require.NoError(t, appPlugged.Start(context.Background())) - - cacheSvc2, err := core.Inject[contracts.CacheService](appPlugged.Context()) - require.NoError(t, err) - require.NoError(t, cacheSvc2.Set(context.Background(), fmt.Sprintf("seq_key_%d", i), "seq_val_plugged", time.Minute)) - - require.NoError(t, appPlugged.Stop(context.Background())) - _ = appPlugged.Context().Dispose() - } - }) -} diff --git a/backend/cmd/relay/main.go b/backend/cmd/relay/main.go new file mode 100644 index 00000000..b1b0d8b9 --- /dev/null +++ b/backend/cmd/relay/main.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Command relay runs the OpenFlare relay node daemon. +package main + +import ( + "flag" + "log/slog" + "os" + "time" + + "Wavelet/core" + relayplugin "Wavelet/openflare/plugins/relay" + edgelogging "Wavelet/openflare/share/edge/logging" +) + +// shutdownTimeout 为 frps 子进程收敛预留的退出窗口。 +const shutdownTimeout = 60 * time.Second + +func main() { + edgelogging.Setup(edgelogging.Options{}) + + configPath := flag.String("config", "./relay.json", "relay config path") + flag.Parse() + + app := core.NewApp( + core.WithProfile(core.Profile(relayplugin.DriverTypeRelay)), + core.WithShutdownTimeout(shutdownTimeout), + ) + app.Use(relayplugin.New(*configPath)) + + if err := app.Prepare(); err != nil { + slog.Error("relay startup failed", "error", err) + os.Exit(1) + } + if err := app.Run(); err != nil { + slog.Error("relay process exited with error", "error", err) + os.Exit(1) + } +} diff --git a/backend/cmd/upgrade_from_golden_test.go b/backend/cmd/upgrade_from_golden_test.go new file mode 100644 index 00000000..7460b8a7 --- /dev/null +++ b/backend/cmd/upgrade_from_golden_test.go @@ -0,0 +1,680 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "io" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + "Wavelet/core" + + "github.com/glebarez/sqlite" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +const ( + goldenRoot = "/Users/ryan/Code/Go/OpenFlare" + goldCommit = "9f79fb99" + goldGooseVersion = int64(202608090003) + sampleZoneDomain = "l3-upgrade-golden.example" + goldMigrateWait = 75 * time.Second + legacyPluginStamp = "openflare/legacy" + serverPluginStamp = "server" +) + +var ( + goldBinOnce sync.Once + goldBinPath string + goldSrcDir string + goldBinErr error +) + +func TestUpgradeFromGolden(t *testing.T) { + t.Run("sqlite", func(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "a.db") + runGoldenAPI(t, tmp, goldSQLiteEnv(t, tmp, dbPath), func() bool { + return sqliteReady(dbPath) + }) + assertUpgradeFromGolden(t, upgradeDB{ + sqlitePath: dbPath, + source: cordisSQLiteSource(t, dbPath), + }) + }) +} + +func TestUpgradePostgresFromGolden(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN")) + if dsn == "" { + t.Skip("TEST_PG_DSN is not set") + } + + host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn) + adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode) + admin := openInspectDB(t, "", adminDSN) + t.Cleanup(func() { _ = admin.Close() }) + + dbName := fmt.Sprintf("of_l3_%d", time.Now().UnixNano()) + if !safePGIdent(dbName) { + t.Fatalf("generated database name %q is not a safe identifier", dbName) + } + if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil { + t.Fatalf("CREATE DATABASE %s: %v", dbName, err) + } + t.Cleanup(func() { + _, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName) + _, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName) + }) + + tmp := t.TempDir() + testDSN := postgresDSN(host, port, user, pass, dbName, sslMode) + runGoldenAPI(t, tmp, goldPostgresEnv(t, tmp, host, port, user, pass, dbName, sslMode), func() bool { + return postgresReady(testDSN) + }) + assertUpgradeFromGolden(t, upgradeDB{ + pgDSN: testDSN, + source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode), + }) +} + +type upgradeDB struct { + sqlitePath string + pgDSN string + source core.ConfigSource +} + +func assertUpgradeFromGolden(t *testing.T, spec upgradeDB) { + t.Helper() + + inspect := openInspectDB(t, spec.sqlitePath, spec.pgDSN) + before := dumpOfSchema(t, inspect, spec.pgDSN != "") + insertSQL := `INSERT INTO of_zones (domain) VALUES (?)` + if spec.pgDSN != "" { + insertSQL = `INSERT INTO of_zones (domain) VALUES ($1)` + } + if _, err := inspect.Exec(insertSQL, sampleZoneDomain); err != nil { + t.Fatalf("insert sample of_zones row: %v", err) + } + _ = inspect.Close() + + app := cordisPrepare(t, spec.source) + legacyRows := schemaPluginRows(t, spec, legacyPluginStamp) + assertStampedUpgrade(t, spec, before, legacyRows) + if err := app.Context().Dispose(); err != nil { + t.Fatalf("dispose first app: %v", err) + } + + app2 := cordisPrepare(t, spec.source) + t.Cleanup(func() { _ = app2.Context().Dispose() }) + if got := schemaPluginRows(t, spec, legacyPluginStamp); got != legacyRows { + t.Fatalf("second Prepare increased %s rows: got %d, want %d", legacyPluginStamp, got, legacyRows) + } + assertStampedUpgrade(t, spec, before, legacyRows) +} + +func cordisPrepare(t *testing.T, src core.ConfigSource) *core.App { + t.Helper() + app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(src)) + if err := app.Prepare(); err != nil { + t.Fatalf("Prepare: %v", err) + } + if err := app.ApplyPlugins(); err != nil { + t.Fatalf("ApplyPlugins: %v", err) + } + if err := app.RunMigrations(); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + return app +} + +func assertStampedUpgrade(t *testing.T, spec upgradeDB, before map[string][]string, legacyRows int) { + t.Helper() + db := openInspectDB(t, spec.sqlitePath, spec.pgDSN) + defer func() { _ = db.Close() }() + postgres := spec.pgDSN != "" + + if got := gooseMaxVersion(t, db); got != goldGooseVersion { + t.Errorf("goose_db_version max = %d, want %d", got, goldGooseVersion) + } + if legacyRows < 2 { + t.Errorf("w_schema_versions %s rows = %d, want at least 2 (0 and %d)", legacyPluginStamp, legacyRows, goldGooseVersion) + } + if !pluginHasVersion(t, db, postgres, legacyPluginStamp, 0) { + t.Errorf("missing w_schema_versions (%s, 0)", legacyPluginStamp) + } + if !pluginHasVersion(t, db, postgres, legacyPluginStamp, goldGooseVersion) { + t.Errorf("missing w_schema_versions (%s, %d)", legacyPluginStamp, goldGooseVersion) + } + if !pluginHasVersion(t, db, postgres, serverPluginStamp, 1) { + t.Errorf("missing w_schema_versions (%s, 1)", serverPluginStamp) + } + + var domain string + q := `SELECT domain FROM of_zones WHERE domain = ?` + if postgres { + q = `SELECT domain FROM of_zones WHERE domain = $1` + } + if err := db.QueryRow(q, sampleZoneDomain).Scan(&domain); err != nil { + t.Errorf("sample of_zones row missing after upgrade: %v", err) + } + + after := dumpOfSchema(t, db, postgres) + for table, cols := range before { + got, ok := after[table] + if !ok { + t.Errorf("of_* table %s dropped", table) + continue + } + have := make(map[string]bool, len(got)) + for _, c := range got { + have[c] = true + } + for _, c := range cols { + if !have[c] { + t.Errorf("of_* column %s.%s dropped", table, c) + } + } + } +} + +func runGoldenAPI(t *testing.T, workDir string, env []string, ready func() bool) { + t.Helper() + bin := buildGoldenBinary(t) + + ctx, cancel := context.WithTimeout(context.Background(), goldMigrateWait) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "api") + cmd.Dir = workDir + cmd.Env = env + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Start(); err != nil { + t.Fatalf("start golden api: %v", err) + } + + waitErr := make(chan error, 1) + go func() { waitErr <- cmd.Wait() }() + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + if ready() { + killGolden(cmd) + <-waitErr + return + } + select { + case err := <-waitErr: + if ready() { + return + } + t.Fatalf("golden api exited before goose %d: %v\n%s", goldGooseVersion, err, out.String()) + case <-ctx.Done(): + killGolden(cmd) + <-waitErr + t.Fatalf("timeout waiting for golden goose %d\n%s", goldGooseVersion, out.String()) + case <-ticker.C: + } + } +} + +func killGolden(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + _ = cmd.Process.Kill() +} + +func buildGoldenBinary(t *testing.T) string { + t.Helper() + goldBinOnce.Do(func() { + src, err := os.MkdirTemp("", "of-gold-src-") + if err != nil { + goldBinErr = err + return + } + archive := exec.Command("git", "-C", goldenRoot, "archive", goldCommit) + extract := exec.Command("tar", "-x", "-C", src) + pipe, err := archive.StdoutPipe() + if err != nil { + goldBinErr = fmt.Errorf("gold archive pipe: %w", err) + return + } + extract.Stdin = pipe + var archiveErr, extractErr bytes.Buffer + archive.Stderr = &archiveErr + extract.Stderr = &extractErr + if err := archive.Start(); err != nil { + goldBinErr = fmt.Errorf("git archive %s: %w", goldCommit, err) + return + } + if err := extract.Start(); err != nil { + _ = archive.Process.Kill() + goldBinErr = fmt.Errorf("extract gold %s: %w", goldCommit, err) + return + } + if err := extract.Wait(); err != nil { + _ = archive.Wait() + goldBinErr = fmt.Errorf("extract gold %s: %w\n%s", goldCommit, err, extractErr.String()) + return + } + if err := archive.Wait(); err != nil { + goldBinErr = fmt.Errorf("git archive %s: %w\n%s", goldCommit, err, archiveErr.String()) + return + } + if _, err := os.Stat(filepath.Join(src, "main.go")); err != nil { + goldBinErr = fmt.Errorf("gold %s at %s: %w", goldCommit, src, err) + return + } + goldSrcDir = src + + dir, err := os.MkdirTemp("", "of-gold-bin-") + if err != nil { + goldBinErr = err + return + } + out := filepath.Join(dir, "gold") + cmd := exec.Command("go", "build", "-o", out, ".") + cmd.Dir = src + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + goldBinErr = fmt.Errorf("go build golden %s: %w\n%s", goldCommit, err, buf.String()) + return + } + goldBinPath = out + }) + if goldBinErr != nil { + t.Fatalf("%v", goldBinErr) + } + return goldBinPath +} + +func copyGoldConfig(t *testing.T, dir string) string { + t.Helper() + buildGoldenBinary(t) + dst := filepath.Join(dir, "config.yaml") + src, err := os.Open(filepath.Join(goldSrcDir, "config.example.yaml")) //nolint:gosec // extracted gold snapshot + if err != nil { + t.Fatalf("open golden config.example.yaml: %v", err) + } + defer func() { _ = src.Close() }() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // test temp file + if err != nil { + t.Fatalf("create temp config.yaml: %v", err) + } + if _, err := io.Copy(out, src); err != nil { + _ = out.Close() + t.Fatalf("copy golden config: %v", err) + } + if err := out.Close(); err != nil { + t.Fatalf("close temp config.yaml: %v", err) + } + return dst +} + +func goldSQLiteEnv(t *testing.T, dir, dbPath string) []string { + t.Helper() + cfg := copyGoldConfig(t, dir) + addr := freeLocalAddr(t) + return filteredGoldEnv( + "CONFIG_PATH="+cfg, + "SQLITE_PATH="+dbPath, + "DB_ENABLED=false", + "REDIS_ENABLED=false", + "CLICKHOUSE_ENABLED=false", + "APP_ENV=testing", + "APP_ADDR="+addr, + ) +} + +func goldPostgresEnv(t *testing.T, dir, host string, port int, user, pass, dbName, sslMode string) []string { + t.Helper() + cfg := copyGoldConfig(t, dir) + addr := freeLocalAddr(t) + return filteredGoldEnv( + "CONFIG_PATH="+cfg, + "DB_ENABLED=true", + "DB_HOST="+host, + "DB_PORT="+strconv.Itoa(port), + "DB_USERNAME="+user, + "DB_PASSWORD="+pass, + "DB_NAME="+dbName, + "DB_SSL_MODE="+sslMode, + "REDIS_ENABLED=false", + "CLICKHOUSE_ENABLED=false", + "APP_ENV=testing", + "APP_ADDR="+addr, + ) +} + +func filteredGoldEnv(extra ...string) []string { + drop := map[string]bool{ + "CONFIG_PATH": true, + "SQLITE_PATH": true, + "DB_ENABLED": true, + "DB_HOST": true, + "DB_PORT": true, + "DB_USERNAME": true, + "DB_PASSWORD": true, + "DB_NAME": true, + "DB_SSL_MODE": true, + "REDIS_ENABLED": true, + "REDIS_ADDR": true, + "CLICKHOUSE_ENABLED": true, + "CLICKHOUSE_HOST": true, + "APP_ENV": true, + "APP_ADDR": true, + } + env := make([]string, 0, len(os.Environ())+len(extra)) + for _, kv := range os.Environ() { + k, _, _ := strings.Cut(kv, "=") + if drop[k] { + continue + } + env = append(env, kv) + } + return append(env, extra...) +} + +func freeLocalAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen for free port: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + return addr +} + +func cordisSQLiteSource(t *testing.T, dbPath string) core.ConfigSource { + t.Helper() + return core.NewMapSource(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + "env": "testing", + }, + "redis": map[string]any{ + "enabled": false, + }, + "clickhouse": map[string]any{ + "enabled": false, + }, + "database": map[string]any{ + "enabled": false, + "sqlite_path": dbPath, + }, + }) +} + +func cordisPostgresSource(t *testing.T, host string, port int, user, pass, dbName, sslMode string) core.ConfigSource { + t.Helper() + return core.NewMapSource(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + "env": "testing", + }, + "redis": map[string]any{ + "enabled": false, + }, + "clickhouse": map[string]any{ + "enabled": false, + }, + "database": map[string]any{ + "enabled": true, + "host": host, + "port": port, + "username": user, + "password": pass, + "database": dbName, + "ssl_mode": sslMode, + }, + }) +} + +func sqliteReady(path string) bool { + if _, err := os.Stat(path); err != nil { + return false + } + gdb, err := gorm.Open(sqlite.Open("file:"+path+"?mode=ro&_pragma=busy_timeout(1000)"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + return false + } + sqlDB, err := gdb.DB() + if err != nil { + return false + } + defer func() { _ = sqlDB.Close() }() + return migratedReady(sqlDB, false) +} + +func postgresReady(dsn string) bool { + gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + return false + } + sqlDB, err := gdb.DB() + if err != nil { + return false + } + defer func() { _ = sqlDB.Close() }() + return migratedReady(sqlDB, true) +} + +func migratedReady(db *sql.DB, postgres bool) bool { + if gooseMaxVersionSilent(db) != goldGooseVersion { + return false + } + var n int + var err error + if postgres { + err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'of_nodes'`).Scan(&n) + } else { + err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'of_nodes'`).Scan(&n) + } + return err == nil && n > 0 +} + +func openInspectDB(t *testing.T, sqlitePath, pgDSN string) *sql.DB { + t.Helper() + var gdb *gorm.DB + var err error + if pgDSN != "" { + gdb, err = gorm.Open(postgres.Open(pgDSN), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + } else { + gdb, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + } + if err != nil { + t.Fatalf("open inspect db: %v", err) + } + sqlDB, err := gdb.DB() + if err != nil { + t.Fatalf("inspect sql.DB: %v", err) + } + return sqlDB +} + +func dumpOfSchema(t *testing.T, db *sql.DB, postgres bool) map[string][]string { + t.Helper() + tables := ofTables(t, db, postgres) + out := make(map[string][]string, len(tables)) + for _, table := range tables { + out[table] = ofColumns(t, db, postgres, table) + } + if len(out) == 0 { + t.Fatal("no of_* tables in golden database") + } + return out +} + +func ofTables(t *testing.T, db *sql.DB, postgres bool) []string { + t.Helper() + var rows *sql.Rows + var err error + if postgres { + rows, err = db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'of_%' ORDER BY tablename`) + } else { + rows, err = db.Query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%' ORDER BY name`) + } + if err != nil { + t.Fatalf("list of_* tables: %v", err) + } + defer func() { _ = rows.Close() }() + var tables []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan of_* table: %v", err) + } + tables = append(tables, name) + } + if err := rows.Err(); err != nil { + t.Fatalf("list of_* tables: %v", err) + } + return tables +} + +func ofColumns(t *testing.T, db *sql.DB, postgres bool, table string) []string { + t.Helper() + var rows *sql.Rows + var err error + if postgres { + rows, err = db.Query(`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, table) + } else { + rows, err = db.Query(`SELECT name FROM pragma_table_info(?)`, table) + } + if err != nil { + t.Fatalf("list columns for %s: %v", table, err) + } + defer func() { _ = rows.Close() }() + var cols []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan column for %s: %v", table, err) + } + cols = append(cols, name) + } + if err := rows.Err(); err != nil { + t.Fatalf("list columns for %s: %v", table, err) + } + return cols +} + +func gooseMaxVersion(t *testing.T, db *sql.DB) int64 { + t.Helper() + v := gooseMaxVersionSilent(db) + if v < 0 { + t.Fatal("read goose_db_version max failed") + } + return v +} + +func gooseMaxVersionSilent(db *sql.DB) int64 { + var v int64 + if err := db.QueryRow(`SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version`).Scan(&v); err != nil { + return -1 + } + return v +} + +func schemaPluginRows(t *testing.T, spec upgradeDB, pluginID string) int { + t.Helper() + db := openInspectDB(t, spec.sqlitePath, spec.pgDSN) + defer func() { _ = db.Close() }() + q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ?` + if spec.pgDSN != "" { + q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1` + } + var n int + if err := db.QueryRow(q, pluginID).Scan(&n); err != nil { + t.Fatalf("count w_schema_versions %s: %v", pluginID, err) + } + return n +} + +func pluginHasVersion(t *testing.T, db *sql.DB, postgres bool, pluginID string, version int64) bool { + t.Helper() + q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ? AND version_id = ?` + if postgres { + q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1 AND version_id = $2` + } + var n int + if err := db.QueryRow(q, pluginID, version).Scan(&n); err != nil { + t.Fatalf("lookup w_schema_versions (%s, %d): %v", pluginID, version, err) + } + return n > 0 +} + +func parsePostgresDSN(t *testing.T, dsn string) (host string, port int, user, pass, dbName, sslMode string) { + t.Helper() + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("TEST_PG_DSN: %v", err) + } + host = u.Hostname() + if host == "" { + host = "127.0.0.1" + } + port = 5432 + if p := u.Port(); p != "" { + port, err = strconv.Atoi(p) + if err != nil { + t.Fatalf("TEST_PG_DSN port: %v", err) + } + } + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + } + dbName = strings.Trim(u.Path, "/") + if dbName == "" { + dbName = "postgres" + } + sslMode = u.Query().Get("sslmode") + if sslMode == "" { + sslMode = "disable" + } + return +} + +func postgresDSN(host string, port int, user, pass, dbName, sslMode string) string { + u := &url.URL{ + Scheme: "postgres", + Host: net.JoinHostPort(host, strconv.Itoa(port)), + Path: dbName, + } + if user != "" { + u.User = url.UserPassword(user, pass) + } + q := url.Values{} + q.Set("sslmode", sslMode) + u.RawQuery = q.Encode() + return u.String() +} + +var pgIdent = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`) + +func safePGIdent(name string) bool { + return pgIdent.MatchString(name) +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 79ac24d2..e90035dd 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -10,8 +10,8 @@ const docTemplate = `{ "description": "{{escape .Description}}", "title": "{{.Title}}", "contact": { - "name": "Wavelet", - "url": "https://github.com/Rain-kl/Wavelet" + "name": "OpenFlare", + "url": "https://github.com/Rain-kl/OpenFlare" }, "license": { "name": "Apache 2.0", @@ -2033,7 +2033,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.SystemConfig" } } } @@ -2228,7 +2228,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.SystemConfig" } } } @@ -2536,7 +2536,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.TaskExecution" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.TaskExecution" } } } @@ -2675,7 +2675,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Schedule" } } } @@ -2737,7 +2737,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Schedule" } } } @@ -2819,7 +2819,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Schedule" } } } @@ -3008,7 +3008,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Template" } } } @@ -3146,7 +3146,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Template" } } } @@ -3226,7 +3226,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Template" } } } @@ -4378,6 +4378,537 @@ const docTemplate = `{ } } }, + "/api/v1/agent/apply-logs": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "记录 Agent 配置下发与应用结果", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "上报配置应用日志", + "parameters": [ + { + "description": "应用日志", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.ApplyLogPayload" + } + } + ], + "responses": { + "200": { + "description": "日志记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/config-versions/active": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "返回当前生效的完整配置包,供 Agent 拉取并应用", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "获取活跃配置版本", + "responses": { + "200": { + "description": "活跃配置", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.ConfigResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/nodes/heartbeat": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "上报节点状态、指标与健康事件,返回远程控制配置与活跃配置元信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "Agent 心跳上报", + "parameters": [ + { + "description": "心跳数据", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.NodePayload" + } + } + ], + "responses": { + "200": { + "description": "心跳成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/nodes/register": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "注册或发现 Agent 节点", + "parameters": [ + { + "description": "节点上报数据", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.NodePayload" + } + } + ], + "responses": { + "200": { + "description": "注册成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.RegistrationResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/deployments/{deployment_id}/hash": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "返回 upload 框架记录的 SHA-256 哈希,供 Agent 对比本地缓存并按需拉取部署包(兼容旧路径)", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "查询 Pages 部署包哈希", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.PagesDeploymentHashResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/deployments/{deployment_id}/package": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "流式下载指定部署的静态资源压缩包,供 Agent 边缘分发(兼容旧路径)", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "openflare-agent" + ], + "summary": "下载 Pages 部署包", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署包文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/projects/{project_id}/latest/hash": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按项目 ID 返回当前激活部署的包哈希(类似 latest 指针),Agent 无需关心具体部署 ID", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "查询 Pages 项目最新激活部署哈希", + "parameters": [ + { + "type": "integer", + "description": "Pages 项目 ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.PagesProjectLatestHashResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/projects/{project_id}/latest/package": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按项目 ID 下载当前激活部署的压缩包,供 Agent 边缘分发", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "openflare-agent" + ], + "summary": "下载 Pages 项目最新激活部署包", + "parameters": [ + { + "type": "integer", + "description": "Pages 项目 ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署包文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/waf/ip-groups/sync": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按 ID 与校验和增量同步 WAF IP 组定义", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "description": "同步请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.WAFIPGroupSyncInput" + } + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.WAFIPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/ws": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "升级为 WebSocket 长连接,用于实时推送配置同步、WAF IP 组等指令;需携带 X-Agent-Token", + "tags": [ + "openflare-agent" + ], + "summary": "Agent WebSocket 连接", + "responses": { + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/cap/challenge": { "get": { "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", @@ -4538,7 +5069,7 @@ const docTemplate = `{ }, "/api/v1/config/public": { "get": { - "description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)", + "description": "返回系统配置表中 visibility 为 1 的配置键值集合", "consumes": [ "application/json" ], @@ -4559,6 +5090,8109 @@ const docTemplate = `{ } } }, + "/api/v1/d/access-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "HTTP 状态码(100-599)", + "name": "status_code", + "in": "query" + }, + { + "type": "string", + "description": "起始时间(RFC3339,需与 until 成对提供)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339,需与 since 成对提供)", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理过期访问日志记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "清理访问日志", + "parameters": [ + { + "description": "清理参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/observability.AccessLogCleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按时间桶聚合访问日志并分页返回,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在指定时间桶内按 IP 聚合访问统计,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "string", + "description": "时间桶起始时间", + "name": "bucket_started_at", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠 IP 汇总列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogIPList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时,1-720,默认 168)", + "name": "hours", + "in": "query" + }, + { + "type": "string", + "description": "开始时间 RFC3339(与 until 同时提供时优先于 hours)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间 RFC3339", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 汇总列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPSummaryList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary/analysis": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定 IP 的汇总指标与 Top 分布,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志 IP 分析", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 访问分析", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPAnalysisView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary/trend": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志 IP 趋势", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "时间桶粒度(分钟)", + "name": "bucket_minutes", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 访问趋势", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPTrendView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志概览", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host(单域名)", + "name": "host", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "请求 Host 列表(多域名精确匹配)", + "name": "hosts", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "趋势桶分钟数(1、3、5 或 60,默认 60)", + "name": "bucket_minutes", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogOverview" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/acme-accounts/default": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统默认 ACME 账号配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取默认 ACME 账号", + "responses": { + "200": { + "description": "默认 ACME 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AcmeAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/apply-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-apply-log" + ], + "summary": "获取配置下发日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID 筛选", + "name": "node_id", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "pageNo", + "in": "query" + }, + { + "type": "integer", + "description": "页码(别名)", + "name": "page_no", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(别名)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "下发日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.ListResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/apply-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理历史下发记录,或删除全部记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-apply-log" + ], + "summary": "清理配置下发日志", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/apply_log.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "保存 Cloudflare 连接", + "parameters": [ + { + "description": "连接参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.ConnectionInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection/clear": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "清除 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection/verify": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "测试 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/domains/available": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取可加入 Cloudflare 指向的域名", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.AvailableDomain" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "创建 Cloudflare 指向分组", + "parameters": [ + { + "description": "分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.GroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组详情", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupDetail" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "删除 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "添加 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "成员参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberCreateInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "移出 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "同步 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.SyncReceipt" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "更新 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + }, + { + "description": "成员参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "同步 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.SyncReceipt" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "更新 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.GroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向总览", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.Overview" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/config-versions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本列表", + "responses": { + "200": { + "description": "配置版本列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ConfigVersionSummary" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/active": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前正在使用的配置版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取当前活跃配置版本", + "responses": { + "200": { + "description": "活跃配置版本", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限、不存在或无活跃版本", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除超出保留数量的非活跃配置版本,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "清理历史配置版本", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config_version.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/diff": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "对比当前草稿配置与活跃版本之间的差异,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "对比草稿与活跃配置", + "responses": { + "200": { + "description": "配置差异", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigDiffResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/preview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "渲染并返回当前草稿配置的预览结果,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "预览当前草稿配置", + "responses": { + "200": { + "description": "配置预览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigPreviewResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/publish": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前草稿配置发布为新版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "发布配置版本", + "parameters": [ + { + "type": "boolean", + "description": "是否强制发布", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "发布成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定配置版本的完整快照与渲染内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本详情", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "配置版本详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定历史版本设为当前活跃配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "激活配置版本", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dashboard/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-dashboard" + ], + "summary": "获取仪表盘概览", + "responses": { + "200": { + "description": "仪表盘概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dashboard.OverviewPayload" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 DNS 账号", + "responses": { + "200": { + "description": "DNS 账号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 DNS 账号", + "parameters": [ + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 DNS 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "删除 DNS 账号", + "parameters": [ + { + "type": "integer", + "description": "DNS 账号 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 DNS 账号", + "parameters": [ + { + "type": "integer", + "description": "DNS 账号 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 DNS 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有节点及最新配置下发记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取节点列表", + "responses": { + "200": { + "description": "节点列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/node.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的边缘节点记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "创建节点", + "parameters": [ + { + "description": "节点参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/node.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/bootstrap-token": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全局节点发现引导令牌,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取引导令牌", + "responses": { + "200": { + "description": "引导令牌", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/bootstrap-token/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "重新生成全局节点发现引导令牌,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "轮换引导令牌", + "responses": { + "200": { + "description": "新引导令牌", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/agent-release": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定节点可用的最新 Agent 版本信息,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取 Agent 发布信息", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发布渠道", + "name": "channel", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Agent 发布信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.AgentReleaseInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/agent-update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发 Agent 自更新指令,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求 Agent 更新", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数(可选)", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/node.AgentUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定节点记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "删除节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/force-sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发强制同步当前活跃配置的指令,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求强制同步配置", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/observability": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定节点的指标、健康事件与流量分析数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取节点可观测性数据", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "返回记录数量上限", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "可观测性数据", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.ObservabilityView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/observability/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清理指定节点的历史健康事件记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "清理节点健康事件", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.HealthEventCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/openresty-restart": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发 OpenResty 重启指令,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求重启 OpenResty", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "重启请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定节点的配置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "更新节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "节点参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/node.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部非敏感 OpenFlare 配置项,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "列出 OpenFlare 配置项", + "responses": { + "200": { + "description": "配置项列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/geoip/lookup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按提供商与 IP 查询地理位置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "GeoIP 地址查询", + "parameters": [ + { + "description": "查询参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.geoIPLookupRequest" + } + } + ], + "responses": { + "200": { + "description": "GeoIP 查询结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.geoIPLookupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新单个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "更新 OpenFlare 配置项", + "parameters": [ + { + "description": "配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update-batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "批量更新多个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "批量更新 OpenFlare 配置项", + "parameters": [ + { + "description": "批量配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.optionBatchPayload" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有源站及关联代理规则数量,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站列表", + "responses": { + "200": { + "description": "源站列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的上游源站记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "创建源站", + "parameters": [ + { + "description": "源站参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/origin.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定源站信息及关联代理规则摘要,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站详情", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "源站详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.DetailView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定源站记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "删除源站", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定源站的配置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "更新源站", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "源站参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/origin.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 项目", + "responses": { + "200": { + "description": "Pages 项目列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "创建 Pages 项目", + "parameters": [ + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/deployments/{deployment_id}/files": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定部署包含的文件清单,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署文件", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署文件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentFileView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 Pages 项目详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 项目详情", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Pages 项目详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定项目的全部部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为指定项目上传静态资源压缩包(zip/tar.gz/tar.xz/tar.bz2/tar/7z),需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "上传 Pages 部署包", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "部署包文件", + "name": "package", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload-from-url": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "从 URL 导入 Pages 部署包", + "deprecated": true, + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "下载链接", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.UploadFromURLInput" + } + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定部署设为项目当前生效版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "激活 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定项目的部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回项目部署源配置与运行状态,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署源", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/check": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "检查 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "任务回执", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceActionReceipt" + } + } + } + ] + } + }, + "400": { + "description": "当前来源不支持检查", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "来源任务正在执行", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "幂等删除持久部署源;已有部署历史与当前生产部署保持不变", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "手动来源视图", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "同步并发布 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "同步参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/pages.SourceSyncInput" + } + } + ], + "responses": { + "200": { + "description": "任务回执", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceActionReceipt" + } + } + } + ] + } + }, + "400": { + "description": "参数或来源类型无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "来源任务正在执行", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "部署源配置", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.SourceUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceUpdateResult" + } + } + } + ] + } + }, + "400": { + "description": "配置无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "更新后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有代理规则配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则列表", + "responses": { + "200": { + "description": "代理规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的反向代理规则,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "创建代理规则", + "parameters": [ + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定代理规则的完整配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则详情", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "代理规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定代理规则,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "删除代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定代理规则的配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "更新代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/status": { + "get": { + "description": "返回版本、认证源与系统公开配置,无需登录", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "获取 OpenFlare 公开状态", + "responses": { + "200": { + "description": "公开状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.statusView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 TLS 证书(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 TLS 证书", + "responses": { + "200": { + "description": "证书列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从 PEM 文本创建 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 TLS 证书", + "parameters": [ + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "通过 ACME 申请新的 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "申请 ACME 证书", + "parameters": [ + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "申请中的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/import-file": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "上传证书与私钥文件创建 TLS 证书,需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "从文件导入 TLS 证书", + "parameters": [ + { + "type": "string", + "description": "证书名称", + "name": "name", + "in": "formData" + }, + { + "type": "string", + "description": "备注", + "name": "remark", + "in": "formData" + }, + { + "type": "file", + "description": "证书文件", + "name": "cert_file", + "in": "formData", + "required": true + }, + { + "type": "file", + "description": "私钥文件", + "name": "key_file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "导入成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取 TLS 证书详情", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "证书详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/content": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回证书与私钥 PEM 内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取 TLS 证书 PEM 内容", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "证书 PEM 内容", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/tls.CertificateContent" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/convert-acme": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将已上传证书转换为 ACME 自动续期模式,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "将证书转为 ACME 管理", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "转换后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 TLS 证书,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "删除 TLS 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/renew": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 ACME 证书续期,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "续期 ACME 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "续期后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 TLS 证书 PEM 信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 TLS 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/update-acme": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 ACME 证书申请配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 ACME 证书配置", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/uptimekuma/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "同步 Uptime Kuma", + "responses": { + "200": { + "description": "同步成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF IP 组", + "responses": { + "200": { + "description": "IP 组列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF IP 组", + "parameters": [ + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "测试 WAF IP 组自动配置", + "parameters": [ + { + "description": "自动配置参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupAutoTestInput" + } + } + ], + "responses": { + "200": { + "description": "测试结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupAutoTestResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 WAF IP 组详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF IP 组详情", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "IP 组详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 WAF IP 组外部 IP 同步,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF 规则", + "responses": { + "200": { + "description": "规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF 规则", + "parameters": [ + { + "description": "规则名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.CreateRuleInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF 规则详情", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF 规则", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/graph": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "保存 WAF 规则图", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则图和修订号", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.SaveRuleGraphInput" + } + } + ], + "responses": { + "200": { + "description": "保存成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数或规则图错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "修订冲突", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/meta": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF 规则元数据", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则元数据", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.UpdateRuleMetaInput" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/sites/{route_id}/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回代理站点关联的 WAF 规则组绑定,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "替换代理站点关联的 WAF 规则组列表,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "替换站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + }, + { + "description": "规则组 ID 列表", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IDsRequest" + } + } + ], + "responses": { + "200": { + "description": "更新后的站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/zone.ListItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "创建 Zone", + "parameters": [ + { + "description": "Zone 参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.Input" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Zone" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "删除 Zone", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "创建 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "域名参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.DomainInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ZoneDomain" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains/{domainId}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "删除 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "域名 ID", + "name": "domainId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains/{domainId}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "更新 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "域名 ID", + "name": "domainId", + "in": "path", + "required": true + }, + { + "description": "域名参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.DomainInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ZoneDomain" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 概览", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/zone.Overview" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 流量统计", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "时间范围:24h(默认)、7d、30d", + "name": "range", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/zone.Stats" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "更新 Zone", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Zone 参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.Input" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Zone" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/message-gateway/bindings": { "get": { "security": [ @@ -5150,6 +13784,324 @@ const docTemplate = `{ } } }, + "/api/v1/relay/heartbeat": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "Relay 节点定期上报运行状态与 frps 观测数据,返回运行时配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-relay" + ], + "summary": "上报 Relay 心跳", + "parameters": [ + { + "description": "心跳载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/relay.HeartbeatPayload" + } + } + ], + "responses": { + "200": { + "description": "心跳响应", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/relay.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Agent Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/relay/ws": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "将已认证的 Relay 连接升级为 WebSocket 长连接,用于配置推送", + "tags": [ + "openflare-relay" + ], + "summary": "升级 Relay WebSocket 连接", + "responses": { + "401": { + "description": "Agent Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/apply-log": { + "post": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "Tunnel 客户端上报配置应用结果,服务端记录下发日志", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "上报 Tunnel 配置下发结果", + "parameters": [ + { + "description": "下发结果载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/flared.ApplyLogPayload" + } + } + ], + "responses": { + "200": { + "description": "下发日志记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/config/active": { + "get": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "返回 Tunnel 客户端当前应应用的完整路由配置(含中继列表与代理定义)", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "获取活跃隧道配置", + "responses": { + "200": { + "description": "隧道配置", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/flared.TunnelConfigResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/heartbeat": { + "post": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "Tunnel 客户端定期上报运行状态与中继连接信息,返回活跃配置元数据与隧道设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "上报 Tunnel 心跳", + "parameters": [ + { + "description": "心跳载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/flared.HeartbeatPayload" + } + } + ], + "responses": { + "200": { + "description": "心跳响应", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/flared.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/ws": { + "get": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "将已认证的 Tunnel 客户端连接升级为 WebSocket 长连接,用于配置推送", + "tags": [ + "openflare-tunnel" + ], + "summary": "升级 Tunnel WebSocket 连接", + "responses": { + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/upload": { "post": { "security": [ @@ -5925,17 +14877,6 @@ const docTemplate = `{ "user" ], "summary": "发送邮箱验证码", - "parameters": [ - { - "description": "目标邮箱", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.sendEmailCodeRequest" - } - } - ], "responses": { "200": { "description": "发送成功", @@ -5948,12 +14889,6 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/response.Any" } - }, - "500": { - "description": "发送失败", - "schema": { - "$ref": "#/definitions/response.Any" - } } } } @@ -6039,6 +14974,701 @@ const docTemplate = `{ } }, "definitions": { + "Wavelet_openflare_share_protocol.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.BufferedObservabilityRecord": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeAccessLog" + } + }, + "captured_at_unix": { + "type": "integer" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeEdgeHealth" + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeMetricSnapshot" + } + } + }, + "Wavelet_openflare_share_protocol.NodeAccessLog": { + "type": "object", + "properties": { + "bytes_sent": { + "description": "body bytes = 已提供数据", + "type": "integer" + }, + "cache_status": { + "description": "$upstream_cache_status", + "type": "string" + }, + "host": { + "type": "string" + }, + "logged_at_unix": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "description": "接收数据", + "type": "integer" + }, + "request_time_ms": { + "description": "optional", + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.NodeEdgeHealth": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "connections": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.NodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.NodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.NodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.PagesDeploymentHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "hash": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.PagesProjectLatestHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "package_size": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "total_size": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "Wavelet_plugins_domain_admin_model.Schedule": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "Wavelet_plugins_domain_admin_model.SystemConfig": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer" + } + } + }, + "Wavelet_plugins_domain_admin_model.TaskExecution": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "error_message": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "log": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "result": { + "type": "string" + }, + "retry_count": { + "type": "integer" + }, + "retryable": { + "type": "boolean" + }, + "started_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/model.TaskExecutionStatus" + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "Wavelet_plugins_domain_admin_model.Template": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "agent.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "agent.ApplyLogPayload": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "agent.ConfigResponse": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "source_config_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/agent.SupportFile" + } + }, + "version": { + "type": "string" + } + } + }, + "agent.HeartbeatResponse": { + "type": "object", + "properties": { + "active_config": { + "$ref": "#/definitions/agent.ActiveConfigMeta" + }, + "agent_settings": { + "$ref": "#/definitions/agent.Settings" + }, + "node": { + "$ref": "#/definitions/model.OpenFlareNode" + }, + "waf_ip_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/agent.WAFIPGroup" + } + } + } + }, + "agent.NodePayload": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeAccessLog" + } + }, + "buffered": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.BufferedObservabilityRecord" + } + }, + "current_version": { + "type": "string" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeEdgeHealth" + }, + "ext_version": { + "type": "string" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeHealthEvent" + } + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeMetricSnapshot" + }, + "ip": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeSystemProfile" + }, + "schema_version": { + "type": "integer" + }, + "version": { + "type": "string" + }, + "waf_ip_group_checksums": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "agent.RegistrationResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + } + } + }, + "agent.Settings": { + "type": "object", + "properties": { + "auto_update": { + "type": "boolean" + }, + "heartbeat_interval": { + "type": "integer" + }, + "restart_openresty_now": { + "type": "boolean" + }, + "update_channel": { + "type": "string" + }, + "update_now": { + "type": "boolean" + }, + "update_repo": { + "type": "string" + }, + "update_tag": { + "type": "string" + }, + "websocket_upgrade_enabled": { + "type": "boolean" + } + } + }, + "agent.SupportFile": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "agent.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "agent.WAFIPGroupSyncInput": { + "type": "object", + "properties": { + "checksums": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "agent.WAFIPGroupSyncResult": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.WAFIPGroup" + } + } + } + }, + "apply_log.CleanupInput": { + "type": "object", + "properties": { + "delete_all": { + "type": "boolean" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.CleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "delete_all": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.ListResult": { + "type": "object", + "properties": { + "current": { + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + }, + "total": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + } + }, "auth.AuthSourceView": { "type": "object", "properties": { @@ -6212,6 +15842,372 @@ const docTemplate = `{ } } }, + "cloudflare.AvailableDomain": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_domain": { + "type": "string" + }, + "zone_id": { + "type": "integer" + } + } + }, + "cloudflare.ConnectionInput": { + "type": "object", + "properties": { + "api_token": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "source": { + "type": "string" + } + } + }, + "cloudflare.ConnectionView": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "dns_account_id": { + "type": "integer" + }, + "ready": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "status": { + "type": "string" + }, + "verified_at": { + "type": "string" + } + } + }, + "cloudflare.GroupDetail": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/cloudflare.GroupItem" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + }, + "cloudflare.GroupInput": { + "type": "object", + "properties": { + "backup_node_id": { + "type": "integer" + }, + "default_proxied": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "primary_node_id": { + "type": "integer" + } + } + }, + "cloudflare.GroupItem": { + "type": "object", + "properties": { + "active_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "backup_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "created_at": { + "type": "string" + }, + "default_proxied": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "member_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "primary_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "updated_at": { + "type": "string" + } + } + }, + "cloudflare.MemberCreateInput": { + "type": "object", + "properties": { + "proxied": { + "type": "boolean" + }, + "zone_domain_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberItem": { + "type": "object", + "properties": { + "desired_ip": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "last_error": { + "type": "string" + }, + "proxied": { + "type": "boolean" + }, + "sync_status": { + "type": "string" + }, + "synced_at": { + "type": "string" + }, + "zone_domain_id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberUpdateInput": { + "type": "object", + "properties": { + "proxied": { + "type": "boolean" + } + } + }, + "cloudflare.NodeOption": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "cloudflare.Overview": { + "type": "object", + "properties": { + "connection": { + "$ref": "#/definitions/cloudflare.ConnectionView" + }, + "error_count": { + "type": "integer" + }, + "group_count": { + "type": "integer" + }, + "member_count": { + "type": "integer" + }, + "ok_count": { + "type": "integer" + }, + "pending_count": { + "type": "integer" + } + } + }, + "cloudflare.SyncReceipt": { + "type": "object", + "properties": { + "task_id": { + "type": "string" + } + } + }, + "config_version.CleanupInput": { + "type": "object", + "properties": { + "keep_count": { + "type": "integer" + } + } + }, + "config_version.CleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, + "config_version.ConfigDiffResult": { + "type": "object", + "properties": { + "active_version": { + "type": "string" + }, + "active_website_count": { + "type": "integer" + }, + "added_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "added_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed_option_details": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.ConfigOptionDiffItem" + } + }, + "changed_option_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "current_website_count": { + "type": "integer" + }, + "main_config_changed": { + "type": "boolean" + }, + "modified_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "modified_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "waf_config_changed": { + "type": "boolean" + } + } + }, + "config_version.ConfigOptionDiffItem": { + "type": "object", + "properties": { + "current_value": { + "type": "string" + }, + "key": { + "type": "string" + }, + "previous_value": { + "type": "string" + } + } + }, + "config_version.ConfigPreviewResult": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "route_config": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "snapshot_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.SupportFile" + } + }, + "website_count": { + "type": "integer" + } + } + }, + "config_version.SupportFile": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "contracts.AuthSourceDTO": { "type": "object", "properties": { @@ -6325,6 +16321,153 @@ const docTemplate = `{ } } }, + "dashboard.Capacity": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "high_cpu_nodes": { + "type": "integer" + }, + "high_memory_nodes": { + "type": "integer" + }, + "high_storage_nodes": { + "type": "integer" + } + } + }, + "dashboard.OverviewPayload": { + "type": "object", + "properties": { + "capacity": { + "$ref": "#/definitions/dashboard.Capacity" + }, + "distributions": { + "$ref": "#/definitions/dashboard.distributionsPayload" + }, + "generated_at": {}, + "nodes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "summary": { + "$ref": "#/definitions/dashboard.Summary" + }, + "traffic": { + "$ref": "#/definitions/dashboard.Traffic" + }, + "trends": { + "$ref": "#/definitions/dashboard.trendsPayload" + } + } + }, + "dashboard.Summary": { + "type": "object", + "properties": { + "offline_nodes": { + "type": "integer" + }, + "online_nodes": { + "type": "integer" + }, + "pending_nodes": { + "type": "integer" + }, + "total_nodes": { + "type": "integer" + }, + "unhealthy_nodes": { + "type": "integer" + } + } + }, + "dashboard.Traffic": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "estimated_qps": { + "type": "number" + }, + "reported_nodes": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitors": { + "type": "integer" + } + } + }, + "dashboard.distributionsPayload": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "status_codes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "top_domains": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, + "dashboard.trendsPayload": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "network_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "traffic_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, "disk.Status": { "type": "object", "properties": { @@ -6348,6 +16491,98 @@ const docTemplate = `{ } } }, + "flared.ApplyLogPayload": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "flared.HeartbeatPayload": { + "type": "object", + "properties": { + "client_version": { + "type": "string" + }, + "connected_relays": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredConnectedRelay" + } + }, + "current_checksum": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "frp_version": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "tunnel_status": { + "type": "string" + } + } + }, + "flared.HeartbeatResponse": { + "type": "object", + "properties": { + "active_config": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.ActiveConfigMeta" + }, + "tunnel_settings": { + "$ref": "#/definitions/protocol.RelaySettings" + } + } + }, + "flared.TunnelConfigResponse": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredProxyEntry" + } + }, + "relays": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredRelayInfo" + } + }, + "version": { + "type": "string" + } + } + }, "handler.batchDownloadRequest": { "type": "object", "required": [ @@ -6549,6 +16784,26 @@ const docTemplate = `{ } } }, + "model.AcmeAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "model.BindRequest": { "type": "object", "properties": { @@ -6637,6 +16892,64 @@ const docTemplate = `{ } } }, + "model.ConfigVersion": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "snapshot_json": { + "type": "string" + }, + "support_files_json": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.ConfigVersionSummary": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "version": { + "type": "string" + } + } + }, "model.CreateChannelRequest": { "type": "object", "properties": { @@ -6874,6 +17187,26 @@ const docTemplate = `{ } } }, + "model.DNSAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "model.DatabaseInfoResponse": { "type": "object", "properties": { @@ -7056,6 +17389,250 @@ const docTemplate = `{ } } }, + "model.OpenFlareApplyLog": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "model.OpenFlareHealthEvent": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "first_triggered_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_triggered_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata_json": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "resolved_at": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.OpenFlareNode": { + "type": "object", + "properties": { + "auto_update_enabled": { + "type": "boolean" + }, + "capabilities_json": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.OpenFlareNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kernel_version": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "model.OpenFlareOption": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "model.PublicChannelDTO": { "type": "object", "properties": { @@ -7147,62 +17724,6 @@ const docTemplate = `{ } } }, - "model.Schedule": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "cron": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.SystemConfig": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer" - } - } - }, "model.SystemStatusResponse": { "type": "object", "properties": { @@ -7292,61 +17813,66 @@ const docTemplate = `{ } } }, - "model.TaskExecution": { + "model.TLSCertificate": { "type": "object", "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, "created_at": { "type": "string" }, - "duration": { - "type": "integer" - }, - "error_message": { - "type": "string" - }, - "finished_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "log": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "payload": { - "type": "string" - }, - "result": { - "type": "string" - }, - "retry_count": { - "type": "integer" - }, - "retryable": { + "disable_cname": { "type": "boolean" }, - "started_at": { + "dns1": { "type": "string" }, - "status": { - "$ref": "#/definitions/model.TaskExecutionStatus" - }, - "task_id": { + "dns2": { "type": "string" }, - "task_name": { + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { "type": "string" }, - "task_type": { + "name": { "type": "string" }, - "triggered_by": { + "not_after": { "type": "string" }, + "not_before": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + }, "updated_at": { "type": "string" } @@ -7367,41 +17893,6 @@ const docTemplate = `{ "TaskExecutionStatusFailed" ] }, - "model.Template": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_system": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, "model.TestPushChannelRequest": { "type": "object", "properties": { @@ -7811,6 +18302,49 @@ const docTemplate = `{ } } }, + "model.Zone": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ZoneDomain": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "proxy_route_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "zone_id": { + "type": "integer" + } + } + }, "models.Upload": { "type": "object", "properties": { @@ -7913,6 +18447,2067 @@ const docTemplate = `{ "UploadStatusDeleted" ] }, + "node.AgentReleaseInfo": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "has_update": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "requested_channel": { + "type": "string" + }, + "requested_tag": { + "type": "string" + }, + "tag_name": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + } + } + }, + "node.AgentUpdateInput": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "tag_name": { + "type": "string" + } + } + }, + "node.BootstrapView": { + "type": "object", + "properties": { + "discovery_token": { + "type": "string" + } + } + }, + "node.HealthEventCleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + } + }, + "node.Input": { + "type": "object", + "properties": { + "auto_update_enabled": { + "type": "boolean" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + } + } + }, + "node.ObservabilityView": { + "type": "object", + "properties": { + "analytics": { + "$ref": "#/definitions/observability.NodeAnalytics" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareHealthEvent" + } + }, + "metric_snapshots": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NodeMetricSnapshotView" + } + }, + "node_id": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/model.OpenFlareNodeSystemProfile" + }, + "relay_dashboard": { + "$ref": "#/definitions/observability.RelayDashboardSnapshot" + }, + "trends": { + "$ref": "#/definitions/observability.NodeTrends" + } + } + }, + "node.View": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": {}, + "latest_apply_at": { + "type": "string" + }, + "latest_apply_checksum": { + "type": "string" + }, + "latest_apply_message": { + "type": "string" + }, + "latest_apply_result": { + "type": "string" + }, + "latest_main_config_checksum": { + "type": "string" + }, + "latest_route_config_checksum": { + "type": "string" + }, + "latest_support_file_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "observability.AccessLogCleanupInput": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogCleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogIPAnalysisSummary": { + "type": "object", + "properties": { + "bandwidth_served": { + "type": "integer" + }, + "bytes_received": { + "type": "integer" + }, + "error_count": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, + "unique_hosts": { + "type": "integer" + }, + "unique_paths": { + "type": "integer" + } + } + }, + "observability.AccessLogIPAnalysisView": { + "type": "object", + "properties": { + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "generated_at": { + "type": "string" + }, + "hours": { + "type": "integer" + }, + "remote_addr": { + "type": "string" + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "summary": { + "$ref": "#/definitions/observability.AccessLogIPAnalysisSummary" + }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_hosts": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_paths": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.AccessLogIPSummaryList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "hours": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPSummaryView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "since": { + "type": "string" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + }, + "until": { + "type": "string" + } + } + }, + "observability.AccessLogIPSummaryView": { + "type": "object", + "properties": { + "bytes_received": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "recent_requests": { + "description": "RecentRequests is deprecated and always 0.", + "type": "integer" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "success_2xx_count": { + "type": "integer" + }, + "success_ratio": { + "type": "number" + }, + "total_requests": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "request_count": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendView": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "hours": { + "type": "integer" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPTrendPoint" + } + }, + "remote_addr": { + "type": "string" + } + } + }, + "observability.AccessLogList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.AccessLogOverview": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "generated_at": { + "type": "string" + }, + "hours": { + "type": "integer" + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "summary": { + "$ref": "#/definitions/observability.AccessLogOverviewSummary" + }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_hosts": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_operating_systems": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_paths": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "trends": { + "$ref": "#/definitions/observability.AccessLogOverviewTrends" + } + } + }, + "observability.AccessLogOverviewMetricPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.AccessLogOverviewSummary": { + "type": "object", + "properties": { + "bandwidth_served": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, + "total_visits": { + "type": "integer" + } + } + }, + "observability.AccessLogOverviewTrends": { + "type": "object", + "properties": { + "bandwidth": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + }, + "requests": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + }, + "visits": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + } + } + }, + "observability.AccessLogView": { + "type": "object", + "properties": { + "bytes_sent": { + "type": "integer" + }, + "cache_status": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logged_at": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "type": "integer" + }, + "request_time_ms": { + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "observability.CapacityTrendPoint": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "bucket_started_at": { + "type": "string" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DiskIOTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DistributionItem": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPList": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogIPView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPView": { + "type": "object", + "properties": { + "client_error_count": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogList": { + "type": "object", + "properties": { + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_bucket": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogView": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "client_error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + }, + "unique_host_count": { + "type": "integer" + }, + "unique_ip_count": { + "type": "integer" + } + } + }, + "observability.HealthSummary": { + "type": "object", + "properties": { + "active_alerts": { + "type": "integer" + }, + "critical_alerts": { + "type": "integer" + }, + "has_capacity_risk": { + "type": "boolean" + }, + "has_runtime_risk": { + "type": "boolean" + }, + "has_traffic_risk": { + "type": "boolean" + }, + "info_alerts": { + "type": "integer" + }, + "resolved_alerts": { + "type": "integer" + }, + "warning_alerts": { + "type": "integer" + } + } + }, + "observability.NetworkTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "bytes_provided": { + "description": "sum(bytes_sent)", + "type": "integer" + }, + "bytes_received": { + "description": "sum(request_length)", + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.NodeAnalytics": { + "type": "object", + "properties": { + "distributions": { + "$ref": "#/definitions/observability.TrafficDistributions" + }, + "health": { + "$ref": "#/definitions/observability.HealthSummary" + }, + "traffic": { + "$ref": "#/definitions/observability.TrafficWindowSummary" + } + } + }, + "observability.NodeMetricSnapshotView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "openresty_connections": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "observability.NodeTrends": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.CapacityTrendPoint" + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DiskIOTrendPoint" + } + }, + "network_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NetworkTrendPoint" + } + }, + "traffic_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.TrafficTrendPoint" + } + } + } + }, + "observability.RelayDashboardSnapshot": { + "type": "object", + "properties": { + "client_counts": { + "type": "integer" + }, + "offline_proxies": { + "type": "integer" + }, + "online_proxies": { + "type": "integer" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.RelayProxyStat" + } + }, + "total_connections": { + "type": "integer" + }, + "total_proxies": { + "type": "integer" + } + } + }, + "observability.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "observability.TrafficDistributions": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.TrafficTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "status_2xx_count": { + "type": "integer" + }, + "status_4xx_count": { + "type": "integer" + }, + "status_5xx_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + } + } + }, + "observability.TrafficWindowSummary": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "error_rate_percent": { + "type": "number" + }, + "estimated_qps": { + "type": "number" + }, + "request_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "option.geoIPLookupRequest": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.geoIPLookupView": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "iso_code": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.optionBatchPayload": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + }, + "option.publicAuthSourceView": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "option.statusView": { + "type": "object", + "properties": { + "auth_sources": { + "type": "array", + "items": { + "$ref": "#/definitions/option.publicAuthSourceView" + } + }, + "cap_login_enabled": { + "type": "boolean" + }, + "email_verification": { + "type": "boolean" + }, + "password_register_enabled": { + "type": "boolean" + }, + "server_address": { + "type": "string" + }, + "start_time": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "origin.DetailView": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.RouteSummary" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.Input": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "origin.RouteSummary": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.View": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "pages.DeploymentFileView": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "pages.DeploymentView": { + "type": "object", + "properties": { + "activated_at": { + "type": "string" + }, + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "deployment_number": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "source_label": { + "type": "string" + }, + "source_type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "total_size": { + "type": "integer" + }, + "trigger_type": { + "type": "string" + }, + "upload_id": { + "type": "string", + "example": "0" + } + } + }, + "pages.Input": { + "type": "object", + "properties": { + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + } + } + }, + "pages.SourceActionReceipt": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "execution_id": { + "type": "string" + }, + "task_id": { + "type": "string" + } + } + }, + "pages.SourceRevisionView": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "pages.SourceSyncInput": { + "type": "object", + "properties": { + "confirmed_revision": { + "type": "string" + } + } + }, + "pages.SourceUpdateInput": { + "type": "object", + "properties": { + "allow_insecure": { + "type": "boolean" + }, + "asset_name": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "check_interval_minutes": { + "type": "integer" + }, + "release_selector": { + "type": "string" + }, + "release_tag": { + "type": "string" + }, + "remote_url": { + "type": "string" + }, + "repository_url": { + "type": "string" + }, + "source_type": { + "type": "string" + } + } + }, + "pages.SourceUpdateResult": { + "type": "object", + "properties": { + "check_task": { + "$ref": "#/definitions/pages.SourceActionReceipt" + }, + "source": { + "$ref": "#/definitions/pages.SourceView" + }, + "warning": { + "type": "string" + } + } + }, + "pages.SourceView": { + "type": "object", + "properties": { + "allow_insecure": { + "type": "boolean" + }, + "asset_name": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "check_interval_minutes": { + "type": "integer" + }, + "github_repository": { + "type": "string" + }, + "last_applied": { + "$ref": "#/definitions/pages.SourceRevisionView" + }, + "last_checked_at": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "last_seen": { + "$ref": "#/definitions/pages.SourceRevisionView" + }, + "last_synced_at": { + "type": "string" + }, + "next_check_at": { + "type": "string" + }, + "release_selector": { + "type": "string" + }, + "release_tag": { + "type": "string" + }, + "remote_url": { + "type": "string" + }, + "source_type": { + "type": "string" + }, + "sync_status": { + "type": "string" + }, + "update_available": { + "type": "boolean" + } + } + }, + "pages.UploadFromURLInput": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, + "pages.View": { + "type": "object", + "properties": { + "active_deployment": { + "$ref": "#/definitions/pages.DeploymentView" + }, + "active_deployment_id": { + "type": "integer" + }, + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_count": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "protocol.AgentNodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "protocol.AgentNodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "protocol.AgentNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "protocol.FlaredConnectedRelay": { + "type": "object", + "properties": { + "proxy_count": { + "type": "integer" + }, + "relay_node_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "protocol.FlaredProxyEntry": { + "type": "object", + "properties": { + "custom_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "local_addr": { + "type": "string" + }, + "local_port": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "protocol.FlaredRelayInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "auth_token": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "relay_node_id": { + "type": "string" + } + } + }, + "protocol.RelayConfig": { + "type": "object", + "properties": { + "auth_token": { + "type": "string" + }, + "bind_port": { + "type": "integer" + }, + "log_level": { + "type": "string" + }, + "vhost_http_port": { + "type": "integer" + }, + "web_server_enabled": { + "type": "boolean" + }, + "web_server_port": { + "type": "integer" + } + } + }, + "protocol.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "protocol.RelaySettings": { + "type": "object", + "properties": { + "auto_update": { + "type": "boolean" + }, + "heartbeat_interval": { + "type": "integer" + }, + "update_channel": { + "type": "string" + }, + "update_now": { + "type": "boolean" + }, + "update_repo": { + "type": "string" + }, + "update_tag": { + "type": "string" + }, + "websocket_upgrade_enabled": { + "type": "boolean" + } + } + }, + "proxy_route.CustomHeaderInput": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "proxy_route.Input": { + "type": "object", + "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rules": { + "type": "array", + "items": { + "type": "string" + } + }, + "custom_headers": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "limit_req_per_ip": { + "type": "string" + }, + "origin_address": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_port": { + "type": "string" + }, + "origin_scheme": { + "type": "string" + }, + "origin_uri": { + "type": "string" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "redirect_http": { + "type": "boolean" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "array", + "items": { + "type": "string" + } + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proxy_route.View": { + "type": "object", + "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rule_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "cache_rules": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "custom_header_list": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "custom_headers": { + "type": "string" + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "limit_req_per_ip": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "redirect_http": { + "type": "boolean" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "upstream_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "string" + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "zone_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.ZoneDomainView" + } + } + } + }, + "proxy_route.ZoneDomainView": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" + } + } + }, "push.Config": { "type": "object", "properties": { @@ -7939,6 +20534,64 @@ const docTemplate = `{ } } }, + "relay.HeartbeatPayload": { + "type": "object", + "properties": { + "frp_version": { + "type": "string" + }, + "frps_client_count": { + "type": "integer" + }, + "frps_connections": { + "type": "integer" + }, + "frps_proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.RelayProxyStat" + } + }, + "frps_proxy_count": { + "type": "integer" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.AgentNodeHealthEvent" + } + }, + "ip": { + "type": "string" + }, + "name": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/protocol.AgentNodeSystemProfile" + }, + "relay_status": { + "type": "string" + }, + "snapshot": { + "$ref": "#/definitions/protocol.AgentNodeMetricSnapshot" + }, + "version": { + "type": "string" + } + } + }, + "relay.HeartbeatResponse": { + "type": "object", + "properties": { + "relay_config": { + "$ref": "#/definitions/protocol.RelayConfig" + }, + "relay_settings": { + "$ref": "#/definitions/protocol.RelaySettings" + } + } + }, "response.Any": { "type": "object", "properties": { @@ -7973,6 +20626,181 @@ const docTemplate = `{ "Hour" ] }, + "tls.ApplyInput": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "auto_renew": { + "type": "boolean" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateContent": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "cert_pem": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateInput": { + "type": "object", + "properties": { + "cert_pem": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "tls.DNSAccountInput": { + "type": "object", + "properties": { + "authorization": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "updater.Status": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, "user.AccessToken": { "type": "object", "properties": { @@ -8064,17 +20892,6 @@ const docTemplate = `{ } } }, - "user.sendEmailCodeRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - } - } - }, "user.updateProfileRequest": { "type": "object", "properties": { @@ -8100,6 +20917,506 @@ const docTemplate = `{ "type": "string" } } + }, + "waf.CreateRuleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "waf.IDsRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestResult": { + "type": "object", + "properties": { + "lookback": { + "type": "string" + }, + "matched_count": { + "type": "integer" + }, + "matched_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "rule_count": { + "type": "integer" + }, + "tested_at": { + "type": "string" + } + } + }, + "waf.IPGroupExtIPView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, + "waf.IPGroupInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "waf.IPGroupSyncResult": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/waf.IPGroupView" + }, + "ip_count": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "synced_at": { + "type": "string" + } + } + }, + "waf.IPGroupView": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ext_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupExtIPView" + } + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "last_sync_message": { + "type": "string" + }, + "last_sync_status": { + "type": "string" + }, + "last_synced_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "referenced_by_rule_count": { + "type": "integer" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.RuleEdge": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "source_handle": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "waf.RuleGraph": { + "type": "object", + "properties": { + "edges": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleEdge" + } + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleNode" + } + }, + "schema_version": { + "type": "integer" + } + } + }, + "waf.RuleNode": { + "type": "object", + "properties": { + "config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "position": { + "$ref": "#/definitions/waf.RulePosition" + }, + "type": { + "$ref": "#/definitions/waf.RuleNodeType" + } + } + }, + "waf.RuleNodeType": { + "type": "string", + "enum": [ + "start", + "allow", + "block", + "ip_match", + "geo_match", + "pow", + "ua_check", + "security_check" + ], + "x-enum-varnames": [ + "RuleNodeStart", + "RuleNodeAllow", + "RuleNodeBlock", + "RuleNodeIPMatch", + "RuleNodeGeoMatch", + "RuleNodePoW", + "RuleNodeUACheck", + "RuleNodeSecurityCheck" + ] + }, + "waf.RulePosition": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "waf.RuleView": { + "type": "object", + "properties": { + "applied_site_count": { + "type": "integer" + }, + "applied_site_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "id": { + "type": "integer" + }, + "is_global": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.SaveRuleGraphInput": { + "type": "object", + "properties": { + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "revision": { + "type": "integer" + } + } + }, + "waf.SiteRuleGroupsView": { + "type": "object", + "properties": { + "applied_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "applied_rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + }, + "global_rule_group": { + "$ref": "#/definitions/waf.RuleView" + }, + "route_id": { + "type": "integer" + }, + "rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + }, + "waf.UpdateRuleMetaInput": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "zone.DomainInput": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, + "zone.Input": { + "type": "object", + "properties": { + "domain": { + "type": "string" + } + } + }, + "zone.ListItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domain_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "zone.Overview": { + "type": "object", + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ZoneDomain" + } + }, + "zone": { + "$ref": "#/definitions/model.Zone" + } + } + }, + "zone.Stats": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "bucket_minutes": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, + "domain_count": { + "type": "integer" + }, + "range": { + "$ref": "#/definitions/zone.StatsRange" + }, + "range_hours": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "series": { + "type": "array", + "items": { + "$ref": "#/definitions/zone.StatsPoint" + } + }, + "unique_visitors": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "zone.StatsPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "bytes_sent": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitors": { + "type": "integer" + } + } + }, + "zone.StatsRange": { + "type": "string", + "enum": [ + "24h", + "7d", + "30d" + ], + "x-enum-varnames": [ + "StatsRange24h", + "StatsRange7d", + "StatsRange30d" + ] } }, "securityDefinitions": { @@ -8117,8 +21434,8 @@ var SwaggerInfo = &swag.Spec{ Host: "", BasePath: "/", Schemes: []string{}, - Title: "Wavelet API", - Description: "Wavelet 平台后端 API,提供用户认证、系统配置、任务调度等通用功能。", + Title: "OpenFlare API", + Description: "OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 85a0289a..3bc39020 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1,11 +1,11 @@ { "swagger": "2.0", "info": { - "description": "Wavelet 平台后端 API,提供用户认证、系统配置、任务调度等通用功能。", - "title": "Wavelet API", + "description": "OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。", + "title": "OpenFlare API", "contact": { - "name": "Wavelet", - "url": "https://github.com/Rain-kl/Wavelet" + "name": "OpenFlare", + "url": "https://github.com/Rain-kl/OpenFlare" }, "license": { "name": "Apache 2.0", @@ -2026,7 +2026,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.SystemConfig" } } } @@ -2221,7 +2221,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.SystemConfig" } } } @@ -2529,7 +2529,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.TaskExecution" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.TaskExecution" } } } @@ -2668,7 +2668,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Schedule" } } } @@ -2730,7 +2730,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Schedule" } } } @@ -2812,7 +2812,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Schedule" } } } @@ -3001,7 +3001,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Template" } } } @@ -3139,7 +3139,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Template" } } } @@ -3219,7 +3219,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/Wavelet_plugins_domain_admin_model.Template" } } } @@ -4371,6 +4371,537 @@ } } }, + "/api/v1/agent/apply-logs": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "记录 Agent 配置下发与应用结果", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "上报配置应用日志", + "parameters": [ + { + "description": "应用日志", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.ApplyLogPayload" + } + } + ], + "responses": { + "200": { + "description": "日志记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/config-versions/active": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "返回当前生效的完整配置包,供 Agent 拉取并应用", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "获取活跃配置版本", + "responses": { + "200": { + "description": "活跃配置", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.ConfigResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/nodes/heartbeat": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "上报节点状态、指标与健康事件,返回远程控制配置与活跃配置元信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "Agent 心跳上报", + "parameters": [ + { + "description": "心跳数据", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.NodePayload" + } + } + ], + "responses": { + "200": { + "description": "心跳成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/nodes/register": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "注册或发现 Agent 节点", + "parameters": [ + { + "description": "节点上报数据", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.NodePayload" + } + } + ], + "responses": { + "200": { + "description": "注册成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.RegistrationResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/deployments/{deployment_id}/hash": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "返回 upload 框架记录的 SHA-256 哈希,供 Agent 对比本地缓存并按需拉取部署包(兼容旧路径)", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "查询 Pages 部署包哈希", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.PagesDeploymentHashResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/deployments/{deployment_id}/package": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "流式下载指定部署的静态资源压缩包,供 Agent 边缘分发(兼容旧路径)", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "openflare-agent" + ], + "summary": "下载 Pages 部署包", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署包文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/projects/{project_id}/latest/hash": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按项目 ID 返回当前激活部署的包哈希(类似 latest 指针),Agent 无需关心具体部署 ID", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "查询 Pages 项目最新激活部署哈希", + "parameters": [ + { + "type": "integer", + "description": "Pages 项目 ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.PagesProjectLatestHashResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/projects/{project_id}/latest/package": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按项目 ID 下载当前激活部署的压缩包,供 Agent 边缘分发", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "openflare-agent" + ], + "summary": "下载 Pages 项目最新激活部署包", + "parameters": [ + { + "type": "integer", + "description": "Pages 项目 ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署包文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/waf/ip-groups/sync": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按 ID 与校验和增量同步 WAF IP 组定义", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "description": "同步请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.WAFIPGroupSyncInput" + } + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.WAFIPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/ws": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "升级为 WebSocket 长连接,用于实时推送配置同步、WAF IP 组等指令;需携带 X-Agent-Token", + "tags": [ + "openflare-agent" + ], + "summary": "Agent WebSocket 连接", + "responses": { + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/cap/challenge": { "get": { "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", @@ -4531,7 +5062,7 @@ }, "/api/v1/config/public": { "get": { - "description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)", + "description": "返回系统配置表中 visibility 为 1 的配置键值集合", "consumes": [ "application/json" ], @@ -4552,6 +5083,8109 @@ } } }, + "/api/v1/d/access-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "HTTP 状态码(100-599)", + "name": "status_code", + "in": "query" + }, + { + "type": "string", + "description": "起始时间(RFC3339,需与 until 成对提供)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339,需与 since 成对提供)", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理过期访问日志记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "清理访问日志", + "parameters": [ + { + "description": "清理参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/observability.AccessLogCleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按时间桶聚合访问日志并分页返回,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在指定时间桶内按 IP 聚合访问统计,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "string", + "description": "时间桶起始时间", + "name": "bucket_started_at", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠 IP 汇总列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogIPList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时,1-720,默认 168)", + "name": "hours", + "in": "query" + }, + { + "type": "string", + "description": "开始时间 RFC3339(与 until 同时提供时优先于 hours)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间 RFC3339", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 汇总列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPSummaryList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary/analysis": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定 IP 的汇总指标与 Top 分布,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志 IP 分析", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 访问分析", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPAnalysisView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary/trend": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志 IP 趋势", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "时间桶粒度(分钟)", + "name": "bucket_minutes", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 访问趋势", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPTrendView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志概览", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host(单域名)", + "name": "host", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "请求 Host 列表(多域名精确匹配)", + "name": "hosts", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "趋势桶分钟数(1、3、5 或 60,默认 60)", + "name": "bucket_minutes", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogOverview" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/acme-accounts/default": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统默认 ACME 账号配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取默认 ACME 账号", + "responses": { + "200": { + "description": "默认 ACME 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AcmeAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/apply-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-apply-log" + ], + "summary": "获取配置下发日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID 筛选", + "name": "node_id", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "pageNo", + "in": "query" + }, + { + "type": "integer", + "description": "页码(别名)", + "name": "page_no", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(别名)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "下发日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.ListResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/apply-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理历史下发记录,或删除全部记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-apply-log" + ], + "summary": "清理配置下发日志", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/apply_log.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "保存 Cloudflare 连接", + "parameters": [ + { + "description": "连接参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.ConnectionInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection/clear": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "清除 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection/verify": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "测试 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/domains/available": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取可加入 Cloudflare 指向的域名", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.AvailableDomain" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "创建 Cloudflare 指向分组", + "parameters": [ + { + "description": "分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.GroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组详情", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupDetail" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "删除 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "添加 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "成员参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberCreateInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "移出 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "同步 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.SyncReceipt" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "更新 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + }, + { + "description": "成员参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "同步 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.SyncReceipt" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "更新 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.GroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向总览", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.Overview" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/config-versions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本列表", + "responses": { + "200": { + "description": "配置版本列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ConfigVersionSummary" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/active": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前正在使用的配置版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取当前活跃配置版本", + "responses": { + "200": { + "description": "活跃配置版本", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限、不存在或无活跃版本", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除超出保留数量的非活跃配置版本,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "清理历史配置版本", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config_version.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/diff": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "对比当前草稿配置与活跃版本之间的差异,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "对比草稿与活跃配置", + "responses": { + "200": { + "description": "配置差异", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigDiffResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/preview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "渲染并返回当前草稿配置的预览结果,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "预览当前草稿配置", + "responses": { + "200": { + "description": "配置预览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigPreviewResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/publish": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前草稿配置发布为新版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "发布配置版本", + "parameters": [ + { + "type": "boolean", + "description": "是否强制发布", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "发布成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定配置版本的完整快照与渲染内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本详情", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "配置版本详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定历史版本设为当前活跃配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "激活配置版本", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dashboard/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-dashboard" + ], + "summary": "获取仪表盘概览", + "responses": { + "200": { + "description": "仪表盘概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dashboard.OverviewPayload" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 DNS 账号", + "responses": { + "200": { + "description": "DNS 账号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 DNS 账号", + "parameters": [ + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 DNS 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "删除 DNS 账号", + "parameters": [ + { + "type": "integer", + "description": "DNS 账号 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 DNS 账号", + "parameters": [ + { + "type": "integer", + "description": "DNS 账号 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 DNS 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有节点及最新配置下发记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取节点列表", + "responses": { + "200": { + "description": "节点列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/node.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的边缘节点记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "创建节点", + "parameters": [ + { + "description": "节点参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/node.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/bootstrap-token": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全局节点发现引导令牌,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取引导令牌", + "responses": { + "200": { + "description": "引导令牌", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/bootstrap-token/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "重新生成全局节点发现引导令牌,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "轮换引导令牌", + "responses": { + "200": { + "description": "新引导令牌", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/agent-release": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定节点可用的最新 Agent 版本信息,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取 Agent 发布信息", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发布渠道", + "name": "channel", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Agent 发布信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.AgentReleaseInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/agent-update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发 Agent 自更新指令,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求 Agent 更新", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数(可选)", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/node.AgentUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定节点记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "删除节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/force-sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发强制同步当前活跃配置的指令,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求强制同步配置", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/observability": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定节点的指标、健康事件与流量分析数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取节点可观测性数据", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "返回记录数量上限", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "可观测性数据", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.ObservabilityView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/observability/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清理指定节点的历史健康事件记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "清理节点健康事件", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.HealthEventCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/openresty-restart": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发 OpenResty 重启指令,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求重启 OpenResty", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "重启请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定节点的配置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "更新节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "节点参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/node.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部非敏感 OpenFlare 配置项,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "列出 OpenFlare 配置项", + "responses": { + "200": { + "description": "配置项列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/geoip/lookup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按提供商与 IP 查询地理位置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "GeoIP 地址查询", + "parameters": [ + { + "description": "查询参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.geoIPLookupRequest" + } + } + ], + "responses": { + "200": { + "description": "GeoIP 查询结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.geoIPLookupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新单个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "更新 OpenFlare 配置项", + "parameters": [ + { + "description": "配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update-batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "批量更新多个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "批量更新 OpenFlare 配置项", + "parameters": [ + { + "description": "批量配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.optionBatchPayload" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有源站及关联代理规则数量,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站列表", + "responses": { + "200": { + "description": "源站列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的上游源站记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "创建源站", + "parameters": [ + { + "description": "源站参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/origin.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定源站信息及关联代理规则摘要,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站详情", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "源站详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.DetailView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定源站记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "删除源站", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定源站的配置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "更新源站", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "源站参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/origin.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 项目", + "responses": { + "200": { + "description": "Pages 项目列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "创建 Pages 项目", + "parameters": [ + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/deployments/{deployment_id}/files": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定部署包含的文件清单,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署文件", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署文件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentFileView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 Pages 项目详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 项目详情", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Pages 项目详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定项目的全部部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为指定项目上传静态资源压缩包(zip/tar.gz/tar.xz/tar.bz2/tar/7z),需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "上传 Pages 部署包", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "部署包文件", + "name": "package", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload-from-url": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "从 URL 导入 Pages 部署包", + "deprecated": true, + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "下载链接", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.UploadFromURLInput" + } + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定部署设为项目当前生效版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "激活 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定项目的部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回项目部署源配置与运行状态,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署源", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/check": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "检查 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "任务回执", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceActionReceipt" + } + } + } + ] + } + }, + "400": { + "description": "当前来源不支持检查", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "来源任务正在执行", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "幂等删除持久部署源;已有部署历史与当前生产部署保持不变", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "手动来源视图", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "同步并发布 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "同步参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/pages.SourceSyncInput" + } + } + ], + "responses": { + "200": { + "description": "任务回执", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceActionReceipt" + } + } + } + ] + } + }, + "400": { + "description": "参数或来源类型无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "来源任务正在执行", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "部署源配置", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.SourceUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceUpdateResult" + } + } + } + ] + } + }, + "400": { + "description": "配置无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "更新后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有代理规则配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则列表", + "responses": { + "200": { + "description": "代理规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的反向代理规则,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "创建代理规则", + "parameters": [ + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定代理规则的完整配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则详情", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "代理规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定代理规则,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "删除代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定代理规则的配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "更新代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/status": { + "get": { + "description": "返回版本、认证源与系统公开配置,无需登录", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "获取 OpenFlare 公开状态", + "responses": { + "200": { + "description": "公开状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.statusView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 TLS 证书(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 TLS 证书", + "responses": { + "200": { + "description": "证书列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从 PEM 文本创建 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 TLS 证书", + "parameters": [ + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "通过 ACME 申请新的 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "申请 ACME 证书", + "parameters": [ + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "申请中的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/import-file": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "上传证书与私钥文件创建 TLS 证书,需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "从文件导入 TLS 证书", + "parameters": [ + { + "type": "string", + "description": "证书名称", + "name": "name", + "in": "formData" + }, + { + "type": "string", + "description": "备注", + "name": "remark", + "in": "formData" + }, + { + "type": "file", + "description": "证书文件", + "name": "cert_file", + "in": "formData", + "required": true + }, + { + "type": "file", + "description": "私钥文件", + "name": "key_file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "导入成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取 TLS 证书详情", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "证书详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/content": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回证书与私钥 PEM 内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取 TLS 证书 PEM 内容", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "证书 PEM 内容", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/tls.CertificateContent" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/convert-acme": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将已上传证书转换为 ACME 自动续期模式,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "将证书转为 ACME 管理", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "转换后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 TLS 证书,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "删除 TLS 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/renew": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 ACME 证书续期,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "续期 ACME 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "续期后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 TLS 证书 PEM 信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 TLS 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/update-acme": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 ACME 证书申请配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 ACME 证书配置", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/uptimekuma/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "同步 Uptime Kuma", + "responses": { + "200": { + "description": "同步成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF IP 组", + "responses": { + "200": { + "description": "IP 组列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF IP 组", + "parameters": [ + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "测试 WAF IP 组自动配置", + "parameters": [ + { + "description": "自动配置参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupAutoTestInput" + } + } + ], + "responses": { + "200": { + "description": "测试结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupAutoTestResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 WAF IP 组详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF IP 组详情", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "IP 组详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 WAF IP 组外部 IP 同步,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF 规则", + "responses": { + "200": { + "description": "规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF 规则", + "parameters": [ + { + "description": "规则名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.CreateRuleInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF 规则详情", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF 规则", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/graph": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "保存 WAF 规则图", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则图和修订号", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.SaveRuleGraphInput" + } + } + ], + "responses": { + "200": { + "description": "保存成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数或规则图错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "修订冲突", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/meta": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF 规则元数据", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则元数据", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.UpdateRuleMetaInput" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/sites/{route_id}/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回代理站点关联的 WAF 规则组绑定,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "替换代理站点关联的 WAF 规则组列表,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "替换站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + }, + { + "description": "规则组 ID 列表", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IDsRequest" + } + } + ], + "responses": { + "200": { + "description": "更新后的站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/zone.ListItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "创建 Zone", + "parameters": [ + { + "description": "Zone 参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.Input" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Zone" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "删除 Zone", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "创建 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "域名参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.DomainInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ZoneDomain" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains/{domainId}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "删除 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "域名 ID", + "name": "domainId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains/{domainId}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "更新 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "域名 ID", + "name": "domainId", + "in": "path", + "required": true + }, + { + "description": "域名参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.DomainInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ZoneDomain" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 概览", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/zone.Overview" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 流量统计", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "时间范围:24h(默认)、7d、30d", + "name": "range", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/zone.Stats" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "更新 Zone", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Zone 参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.Input" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Zone" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/message-gateway/bindings": { "get": { "security": [ @@ -5143,6 +13777,324 @@ } } }, + "/api/v1/relay/heartbeat": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "Relay 节点定期上报运行状态与 frps 观测数据,返回运行时配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-relay" + ], + "summary": "上报 Relay 心跳", + "parameters": [ + { + "description": "心跳载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/relay.HeartbeatPayload" + } + } + ], + "responses": { + "200": { + "description": "心跳响应", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/relay.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Agent Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/relay/ws": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "将已认证的 Relay 连接升级为 WebSocket 长连接,用于配置推送", + "tags": [ + "openflare-relay" + ], + "summary": "升级 Relay WebSocket 连接", + "responses": { + "401": { + "description": "Agent Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/apply-log": { + "post": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "Tunnel 客户端上报配置应用结果,服务端记录下发日志", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "上报 Tunnel 配置下发结果", + "parameters": [ + { + "description": "下发结果载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/flared.ApplyLogPayload" + } + } + ], + "responses": { + "200": { + "description": "下发日志记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/config/active": { + "get": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "返回 Tunnel 客户端当前应应用的完整路由配置(含中继列表与代理定义)", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "获取活跃隧道配置", + "responses": { + "200": { + "description": "隧道配置", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/flared.TunnelConfigResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/heartbeat": { + "post": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "Tunnel 客户端定期上报运行状态与中继连接信息,返回活跃配置元数据与隧道设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "上报 Tunnel 心跳", + "parameters": [ + { + "description": "心跳载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/flared.HeartbeatPayload" + } + } + ], + "responses": { + "200": { + "description": "心跳响应", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/flared.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/ws": { + "get": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "将已认证的 Tunnel 客户端连接升级为 WebSocket 长连接,用于配置推送", + "tags": [ + "openflare-tunnel" + ], + "summary": "升级 Tunnel WebSocket 连接", + "responses": { + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/upload": { "post": { "security": [ @@ -5918,17 +14870,6 @@ "user" ], "summary": "发送邮箱验证码", - "parameters": [ - { - "description": "目标邮箱", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.sendEmailCodeRequest" - } - } - ], "responses": { "200": { "description": "发送成功", @@ -5941,12 +14882,6 @@ "schema": { "$ref": "#/definitions/response.Any" } - }, - "500": { - "description": "发送失败", - "schema": { - "$ref": "#/definitions/response.Any" - } } } } @@ -6032,6 +14967,701 @@ } }, "definitions": { + "Wavelet_openflare_share_protocol.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.BufferedObservabilityRecord": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeAccessLog" + } + }, + "captured_at_unix": { + "type": "integer" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeEdgeHealth" + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeMetricSnapshot" + } + } + }, + "Wavelet_openflare_share_protocol.NodeAccessLog": { + "type": "object", + "properties": { + "bytes_sent": { + "description": "body bytes = 已提供数据", + "type": "integer" + }, + "cache_status": { + "description": "$upstream_cache_status", + "type": "string" + }, + "host": { + "type": "string" + }, + "logged_at_unix": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "description": "接收数据", + "type": "integer" + }, + "request_time_ms": { + "description": "optional", + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.NodeEdgeHealth": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "connections": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.NodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.NodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.NodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.PagesDeploymentHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "hash": { + "type": "string" + } + } + }, + "Wavelet_openflare_share_protocol.PagesProjectLatestHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "package_size": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "total_size": { + "type": "integer" + } + } + }, + "Wavelet_openflare_share_protocol.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "Wavelet_plugins_domain_admin_model.Schedule": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "Wavelet_plugins_domain_admin_model.SystemConfig": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer" + } + } + }, + "Wavelet_plugins_domain_admin_model.TaskExecution": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "error_message": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "log": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "result": { + "type": "string" + }, + "retry_count": { + "type": "integer" + }, + "retryable": { + "type": "boolean" + }, + "started_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/model.TaskExecutionStatus" + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "Wavelet_plugins_domain_admin_model.Template": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "agent.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "agent.ApplyLogPayload": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "agent.ConfigResponse": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "source_config_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/agent.SupportFile" + } + }, + "version": { + "type": "string" + } + } + }, + "agent.HeartbeatResponse": { + "type": "object", + "properties": { + "active_config": { + "$ref": "#/definitions/agent.ActiveConfigMeta" + }, + "agent_settings": { + "$ref": "#/definitions/agent.Settings" + }, + "node": { + "$ref": "#/definitions/model.OpenFlareNode" + }, + "waf_ip_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/agent.WAFIPGroup" + } + } + } + }, + "agent.NodePayload": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeAccessLog" + } + }, + "buffered": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.BufferedObservabilityRecord" + } + }, + "current_version": { + "type": "string" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeEdgeHealth" + }, + "ext_version": { + "type": "string" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeHealthEvent" + } + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeMetricSnapshot" + }, + "ip": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.NodeSystemProfile" + }, + "schema_version": { + "type": "integer" + }, + "version": { + "type": "string" + }, + "waf_ip_group_checksums": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "agent.RegistrationResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + } + } + }, + "agent.Settings": { + "type": "object", + "properties": { + "auto_update": { + "type": "boolean" + }, + "heartbeat_interval": { + "type": "integer" + }, + "restart_openresty_now": { + "type": "boolean" + }, + "update_channel": { + "type": "string" + }, + "update_now": { + "type": "boolean" + }, + "update_repo": { + "type": "string" + }, + "update_tag": { + "type": "string" + }, + "websocket_upgrade_enabled": { + "type": "boolean" + } + } + }, + "agent.SupportFile": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "agent.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "agent.WAFIPGroupSyncInput": { + "type": "object", + "properties": { + "checksums": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "agent.WAFIPGroupSyncResult": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.WAFIPGroup" + } + } + } + }, + "apply_log.CleanupInput": { + "type": "object", + "properties": { + "delete_all": { + "type": "boolean" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.CleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "delete_all": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.ListResult": { + "type": "object", + "properties": { + "current": { + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + }, + "total": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + } + }, "auth.AuthSourceView": { "type": "object", "properties": { @@ -6205,6 +15835,372 @@ } } }, + "cloudflare.AvailableDomain": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_domain": { + "type": "string" + }, + "zone_id": { + "type": "integer" + } + } + }, + "cloudflare.ConnectionInput": { + "type": "object", + "properties": { + "api_token": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "source": { + "type": "string" + } + } + }, + "cloudflare.ConnectionView": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "dns_account_id": { + "type": "integer" + }, + "ready": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "status": { + "type": "string" + }, + "verified_at": { + "type": "string" + } + } + }, + "cloudflare.GroupDetail": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/cloudflare.GroupItem" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + }, + "cloudflare.GroupInput": { + "type": "object", + "properties": { + "backup_node_id": { + "type": "integer" + }, + "default_proxied": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "primary_node_id": { + "type": "integer" + } + } + }, + "cloudflare.GroupItem": { + "type": "object", + "properties": { + "active_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "backup_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "created_at": { + "type": "string" + }, + "default_proxied": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "member_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "primary_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "updated_at": { + "type": "string" + } + } + }, + "cloudflare.MemberCreateInput": { + "type": "object", + "properties": { + "proxied": { + "type": "boolean" + }, + "zone_domain_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberItem": { + "type": "object", + "properties": { + "desired_ip": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "last_error": { + "type": "string" + }, + "proxied": { + "type": "boolean" + }, + "sync_status": { + "type": "string" + }, + "synced_at": { + "type": "string" + }, + "zone_domain_id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberUpdateInput": { + "type": "object", + "properties": { + "proxied": { + "type": "boolean" + } + } + }, + "cloudflare.NodeOption": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "cloudflare.Overview": { + "type": "object", + "properties": { + "connection": { + "$ref": "#/definitions/cloudflare.ConnectionView" + }, + "error_count": { + "type": "integer" + }, + "group_count": { + "type": "integer" + }, + "member_count": { + "type": "integer" + }, + "ok_count": { + "type": "integer" + }, + "pending_count": { + "type": "integer" + } + } + }, + "cloudflare.SyncReceipt": { + "type": "object", + "properties": { + "task_id": { + "type": "string" + } + } + }, + "config_version.CleanupInput": { + "type": "object", + "properties": { + "keep_count": { + "type": "integer" + } + } + }, + "config_version.CleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, + "config_version.ConfigDiffResult": { + "type": "object", + "properties": { + "active_version": { + "type": "string" + }, + "active_website_count": { + "type": "integer" + }, + "added_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "added_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed_option_details": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.ConfigOptionDiffItem" + } + }, + "changed_option_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "current_website_count": { + "type": "integer" + }, + "main_config_changed": { + "type": "boolean" + }, + "modified_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "modified_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "waf_config_changed": { + "type": "boolean" + } + } + }, + "config_version.ConfigOptionDiffItem": { + "type": "object", + "properties": { + "current_value": { + "type": "string" + }, + "key": { + "type": "string" + }, + "previous_value": { + "type": "string" + } + } + }, + "config_version.ConfigPreviewResult": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "route_config": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "snapshot_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.SupportFile" + } + }, + "website_count": { + "type": "integer" + } + } + }, + "config_version.SupportFile": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "contracts.AuthSourceDTO": { "type": "object", "properties": { @@ -6318,6 +16314,153 @@ } } }, + "dashboard.Capacity": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "high_cpu_nodes": { + "type": "integer" + }, + "high_memory_nodes": { + "type": "integer" + }, + "high_storage_nodes": { + "type": "integer" + } + } + }, + "dashboard.OverviewPayload": { + "type": "object", + "properties": { + "capacity": { + "$ref": "#/definitions/dashboard.Capacity" + }, + "distributions": { + "$ref": "#/definitions/dashboard.distributionsPayload" + }, + "generated_at": {}, + "nodes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "summary": { + "$ref": "#/definitions/dashboard.Summary" + }, + "traffic": { + "$ref": "#/definitions/dashboard.Traffic" + }, + "trends": { + "$ref": "#/definitions/dashboard.trendsPayload" + } + } + }, + "dashboard.Summary": { + "type": "object", + "properties": { + "offline_nodes": { + "type": "integer" + }, + "online_nodes": { + "type": "integer" + }, + "pending_nodes": { + "type": "integer" + }, + "total_nodes": { + "type": "integer" + }, + "unhealthy_nodes": { + "type": "integer" + } + } + }, + "dashboard.Traffic": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "estimated_qps": { + "type": "number" + }, + "reported_nodes": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitors": { + "type": "integer" + } + } + }, + "dashboard.distributionsPayload": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "status_codes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "top_domains": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, + "dashboard.trendsPayload": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "network_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "traffic_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, "disk.Status": { "type": "object", "properties": { @@ -6341,6 +16484,98 @@ } } }, + "flared.ApplyLogPayload": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "flared.HeartbeatPayload": { + "type": "object", + "properties": { + "client_version": { + "type": "string" + }, + "connected_relays": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredConnectedRelay" + } + }, + "current_checksum": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "frp_version": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "tunnel_status": { + "type": "string" + } + } + }, + "flared.HeartbeatResponse": { + "type": "object", + "properties": { + "active_config": { + "$ref": "#/definitions/Wavelet_openflare_share_protocol.ActiveConfigMeta" + }, + "tunnel_settings": { + "$ref": "#/definitions/protocol.RelaySettings" + } + } + }, + "flared.TunnelConfigResponse": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredProxyEntry" + } + }, + "relays": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredRelayInfo" + } + }, + "version": { + "type": "string" + } + } + }, "handler.batchDownloadRequest": { "type": "object", "required": [ @@ -6542,6 +16777,26 @@ } } }, + "model.AcmeAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "model.BindRequest": { "type": "object", "properties": { @@ -6630,6 +16885,64 @@ } } }, + "model.ConfigVersion": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "snapshot_json": { + "type": "string" + }, + "support_files_json": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.ConfigVersionSummary": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "version": { + "type": "string" + } + } + }, "model.CreateChannelRequest": { "type": "object", "properties": { @@ -6867,6 +17180,26 @@ } } }, + "model.DNSAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "model.DatabaseInfoResponse": { "type": "object", "properties": { @@ -7049,6 +17382,250 @@ } } }, + "model.OpenFlareApplyLog": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "model.OpenFlareHealthEvent": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "first_triggered_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_triggered_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata_json": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "resolved_at": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.OpenFlareNode": { + "type": "object", + "properties": { + "auto_update_enabled": { + "type": "boolean" + }, + "capabilities_json": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.OpenFlareNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kernel_version": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "model.OpenFlareOption": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "model.PublicChannelDTO": { "type": "object", "properties": { @@ -7140,62 +17717,6 @@ } } }, - "model.Schedule": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "cron": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.SystemConfig": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer" - } - } - }, "model.SystemStatusResponse": { "type": "object", "properties": { @@ -7285,61 +17806,66 @@ } } }, - "model.TaskExecution": { + "model.TLSCertificate": { "type": "object", "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, "created_at": { "type": "string" }, - "duration": { - "type": "integer" - }, - "error_message": { - "type": "string" - }, - "finished_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "log": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "payload": { - "type": "string" - }, - "result": { - "type": "string" - }, - "retry_count": { - "type": "integer" - }, - "retryable": { + "disable_cname": { "type": "boolean" }, - "started_at": { + "dns1": { "type": "string" }, - "status": { - "$ref": "#/definitions/model.TaskExecutionStatus" - }, - "task_id": { + "dns2": { "type": "string" }, - "task_name": { + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { "type": "string" }, - "task_type": { + "name": { "type": "string" }, - "triggered_by": { + "not_after": { "type": "string" }, + "not_before": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + }, "updated_at": { "type": "string" } @@ -7360,41 +17886,6 @@ "TaskExecutionStatusFailed" ] }, - "model.Template": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_system": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, "model.TestPushChannelRequest": { "type": "object", "properties": { @@ -7804,6 +18295,49 @@ } } }, + "model.Zone": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ZoneDomain": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "proxy_route_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "zone_id": { + "type": "integer" + } + } + }, "models.Upload": { "type": "object", "properties": { @@ -7906,6 +18440,2067 @@ "UploadStatusDeleted" ] }, + "node.AgentReleaseInfo": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "has_update": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "requested_channel": { + "type": "string" + }, + "requested_tag": { + "type": "string" + }, + "tag_name": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + } + } + }, + "node.AgentUpdateInput": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "tag_name": { + "type": "string" + } + } + }, + "node.BootstrapView": { + "type": "object", + "properties": { + "discovery_token": { + "type": "string" + } + } + }, + "node.HealthEventCleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + } + }, + "node.Input": { + "type": "object", + "properties": { + "auto_update_enabled": { + "type": "boolean" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + } + } + }, + "node.ObservabilityView": { + "type": "object", + "properties": { + "analytics": { + "$ref": "#/definitions/observability.NodeAnalytics" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareHealthEvent" + } + }, + "metric_snapshots": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NodeMetricSnapshotView" + } + }, + "node_id": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/model.OpenFlareNodeSystemProfile" + }, + "relay_dashboard": { + "$ref": "#/definitions/observability.RelayDashboardSnapshot" + }, + "trends": { + "$ref": "#/definitions/observability.NodeTrends" + } + } + }, + "node.View": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": {}, + "latest_apply_at": { + "type": "string" + }, + "latest_apply_checksum": { + "type": "string" + }, + "latest_apply_message": { + "type": "string" + }, + "latest_apply_result": { + "type": "string" + }, + "latest_main_config_checksum": { + "type": "string" + }, + "latest_route_config_checksum": { + "type": "string" + }, + "latest_support_file_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "observability.AccessLogCleanupInput": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogCleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogIPAnalysisSummary": { + "type": "object", + "properties": { + "bandwidth_served": { + "type": "integer" + }, + "bytes_received": { + "type": "integer" + }, + "error_count": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, + "unique_hosts": { + "type": "integer" + }, + "unique_paths": { + "type": "integer" + } + } + }, + "observability.AccessLogIPAnalysisView": { + "type": "object", + "properties": { + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "generated_at": { + "type": "string" + }, + "hours": { + "type": "integer" + }, + "remote_addr": { + "type": "string" + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "summary": { + "$ref": "#/definitions/observability.AccessLogIPAnalysisSummary" + }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_hosts": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_paths": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.AccessLogIPSummaryList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "hours": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPSummaryView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "since": { + "type": "string" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + }, + "until": { + "type": "string" + } + } + }, + "observability.AccessLogIPSummaryView": { + "type": "object", + "properties": { + "bytes_received": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "recent_requests": { + "description": "RecentRequests is deprecated and always 0.", + "type": "integer" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "success_2xx_count": { + "type": "integer" + }, + "success_ratio": { + "type": "number" + }, + "total_requests": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "request_count": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendView": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "hours": { + "type": "integer" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPTrendPoint" + } + }, + "remote_addr": { + "type": "string" + } + } + }, + "observability.AccessLogList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.AccessLogOverview": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "generated_at": { + "type": "string" + }, + "hours": { + "type": "integer" + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "summary": { + "$ref": "#/definitions/observability.AccessLogOverviewSummary" + }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_hosts": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_operating_systems": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_paths": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "trends": { + "$ref": "#/definitions/observability.AccessLogOverviewTrends" + } + } + }, + "observability.AccessLogOverviewMetricPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.AccessLogOverviewSummary": { + "type": "object", + "properties": { + "bandwidth_served": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, + "total_visits": { + "type": "integer" + } + } + }, + "observability.AccessLogOverviewTrends": { + "type": "object", + "properties": { + "bandwidth": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + }, + "requests": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + }, + "visits": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + } + } + }, + "observability.AccessLogView": { + "type": "object", + "properties": { + "bytes_sent": { + "type": "integer" + }, + "cache_status": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logged_at": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "type": "integer" + }, + "request_time_ms": { + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "observability.CapacityTrendPoint": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "bucket_started_at": { + "type": "string" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DiskIOTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DistributionItem": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPList": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogIPView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPView": { + "type": "object", + "properties": { + "client_error_count": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogList": { + "type": "object", + "properties": { + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_bucket": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogView": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "client_error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + }, + "unique_host_count": { + "type": "integer" + }, + "unique_ip_count": { + "type": "integer" + } + } + }, + "observability.HealthSummary": { + "type": "object", + "properties": { + "active_alerts": { + "type": "integer" + }, + "critical_alerts": { + "type": "integer" + }, + "has_capacity_risk": { + "type": "boolean" + }, + "has_runtime_risk": { + "type": "boolean" + }, + "has_traffic_risk": { + "type": "boolean" + }, + "info_alerts": { + "type": "integer" + }, + "resolved_alerts": { + "type": "integer" + }, + "warning_alerts": { + "type": "integer" + } + } + }, + "observability.NetworkTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "bytes_provided": { + "description": "sum(bytes_sent)", + "type": "integer" + }, + "bytes_received": { + "description": "sum(request_length)", + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.NodeAnalytics": { + "type": "object", + "properties": { + "distributions": { + "$ref": "#/definitions/observability.TrafficDistributions" + }, + "health": { + "$ref": "#/definitions/observability.HealthSummary" + }, + "traffic": { + "$ref": "#/definitions/observability.TrafficWindowSummary" + } + } + }, + "observability.NodeMetricSnapshotView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "openresty_connections": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "observability.NodeTrends": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.CapacityTrendPoint" + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DiskIOTrendPoint" + } + }, + "network_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NetworkTrendPoint" + } + }, + "traffic_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.TrafficTrendPoint" + } + } + } + }, + "observability.RelayDashboardSnapshot": { + "type": "object", + "properties": { + "client_counts": { + "type": "integer" + }, + "offline_proxies": { + "type": "integer" + }, + "online_proxies": { + "type": "integer" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.RelayProxyStat" + } + }, + "total_connections": { + "type": "integer" + }, + "total_proxies": { + "type": "integer" + } + } + }, + "observability.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "observability.TrafficDistributions": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.TrafficTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "status_2xx_count": { + "type": "integer" + }, + "status_4xx_count": { + "type": "integer" + }, + "status_5xx_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + } + } + }, + "observability.TrafficWindowSummary": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "error_rate_percent": { + "type": "number" + }, + "estimated_qps": { + "type": "number" + }, + "request_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "option.geoIPLookupRequest": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.geoIPLookupView": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "iso_code": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.optionBatchPayload": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + }, + "option.publicAuthSourceView": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "option.statusView": { + "type": "object", + "properties": { + "auth_sources": { + "type": "array", + "items": { + "$ref": "#/definitions/option.publicAuthSourceView" + } + }, + "cap_login_enabled": { + "type": "boolean" + }, + "email_verification": { + "type": "boolean" + }, + "password_register_enabled": { + "type": "boolean" + }, + "server_address": { + "type": "string" + }, + "start_time": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "origin.DetailView": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.RouteSummary" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.Input": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "origin.RouteSummary": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.View": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "pages.DeploymentFileView": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "pages.DeploymentView": { + "type": "object", + "properties": { + "activated_at": { + "type": "string" + }, + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "deployment_number": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "source_label": { + "type": "string" + }, + "source_type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "total_size": { + "type": "integer" + }, + "trigger_type": { + "type": "string" + }, + "upload_id": { + "type": "string", + "example": "0" + } + } + }, + "pages.Input": { + "type": "object", + "properties": { + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + } + } + }, + "pages.SourceActionReceipt": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "execution_id": { + "type": "string" + }, + "task_id": { + "type": "string" + } + } + }, + "pages.SourceRevisionView": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "pages.SourceSyncInput": { + "type": "object", + "properties": { + "confirmed_revision": { + "type": "string" + } + } + }, + "pages.SourceUpdateInput": { + "type": "object", + "properties": { + "allow_insecure": { + "type": "boolean" + }, + "asset_name": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "check_interval_minutes": { + "type": "integer" + }, + "release_selector": { + "type": "string" + }, + "release_tag": { + "type": "string" + }, + "remote_url": { + "type": "string" + }, + "repository_url": { + "type": "string" + }, + "source_type": { + "type": "string" + } + } + }, + "pages.SourceUpdateResult": { + "type": "object", + "properties": { + "check_task": { + "$ref": "#/definitions/pages.SourceActionReceipt" + }, + "source": { + "$ref": "#/definitions/pages.SourceView" + }, + "warning": { + "type": "string" + } + } + }, + "pages.SourceView": { + "type": "object", + "properties": { + "allow_insecure": { + "type": "boolean" + }, + "asset_name": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "check_interval_minutes": { + "type": "integer" + }, + "github_repository": { + "type": "string" + }, + "last_applied": { + "$ref": "#/definitions/pages.SourceRevisionView" + }, + "last_checked_at": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "last_seen": { + "$ref": "#/definitions/pages.SourceRevisionView" + }, + "last_synced_at": { + "type": "string" + }, + "next_check_at": { + "type": "string" + }, + "release_selector": { + "type": "string" + }, + "release_tag": { + "type": "string" + }, + "remote_url": { + "type": "string" + }, + "source_type": { + "type": "string" + }, + "sync_status": { + "type": "string" + }, + "update_available": { + "type": "boolean" + } + } + }, + "pages.UploadFromURLInput": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, + "pages.View": { + "type": "object", + "properties": { + "active_deployment": { + "$ref": "#/definitions/pages.DeploymentView" + }, + "active_deployment_id": { + "type": "integer" + }, + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_count": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "protocol.AgentNodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "protocol.AgentNodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "protocol.AgentNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "protocol.FlaredConnectedRelay": { + "type": "object", + "properties": { + "proxy_count": { + "type": "integer" + }, + "relay_node_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "protocol.FlaredProxyEntry": { + "type": "object", + "properties": { + "custom_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "local_addr": { + "type": "string" + }, + "local_port": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "protocol.FlaredRelayInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "auth_token": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "relay_node_id": { + "type": "string" + } + } + }, + "protocol.RelayConfig": { + "type": "object", + "properties": { + "auth_token": { + "type": "string" + }, + "bind_port": { + "type": "integer" + }, + "log_level": { + "type": "string" + }, + "vhost_http_port": { + "type": "integer" + }, + "web_server_enabled": { + "type": "boolean" + }, + "web_server_port": { + "type": "integer" + } + } + }, + "protocol.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "protocol.RelaySettings": { + "type": "object", + "properties": { + "auto_update": { + "type": "boolean" + }, + "heartbeat_interval": { + "type": "integer" + }, + "update_channel": { + "type": "string" + }, + "update_now": { + "type": "boolean" + }, + "update_repo": { + "type": "string" + }, + "update_tag": { + "type": "string" + }, + "websocket_upgrade_enabled": { + "type": "boolean" + } + } + }, + "proxy_route.CustomHeaderInput": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "proxy_route.Input": { + "type": "object", + "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rules": { + "type": "array", + "items": { + "type": "string" + } + }, + "custom_headers": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "limit_req_per_ip": { + "type": "string" + }, + "origin_address": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_port": { + "type": "string" + }, + "origin_scheme": { + "type": "string" + }, + "origin_uri": { + "type": "string" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "redirect_http": { + "type": "boolean" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "array", + "items": { + "type": "string" + } + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proxy_route.View": { + "type": "object", + "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rule_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "cache_rules": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "custom_header_list": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "custom_headers": { + "type": "string" + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "limit_req_per_ip": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "redirect_http": { + "type": "boolean" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "upstream_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "string" + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "zone_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.ZoneDomainView" + } + } + } + }, + "proxy_route.ZoneDomainView": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" + } + } + }, "push.Config": { "type": "object", "properties": { @@ -7932,6 +20527,64 @@ } } }, + "relay.HeartbeatPayload": { + "type": "object", + "properties": { + "frp_version": { + "type": "string" + }, + "frps_client_count": { + "type": "integer" + }, + "frps_connections": { + "type": "integer" + }, + "frps_proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.RelayProxyStat" + } + }, + "frps_proxy_count": { + "type": "integer" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.AgentNodeHealthEvent" + } + }, + "ip": { + "type": "string" + }, + "name": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/protocol.AgentNodeSystemProfile" + }, + "relay_status": { + "type": "string" + }, + "snapshot": { + "$ref": "#/definitions/protocol.AgentNodeMetricSnapshot" + }, + "version": { + "type": "string" + } + } + }, + "relay.HeartbeatResponse": { + "type": "object", + "properties": { + "relay_config": { + "$ref": "#/definitions/protocol.RelayConfig" + }, + "relay_settings": { + "$ref": "#/definitions/protocol.RelaySettings" + } + } + }, "response.Any": { "type": "object", "properties": { @@ -7966,6 +20619,181 @@ "Hour" ] }, + "tls.ApplyInput": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "auto_renew": { + "type": "boolean" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateContent": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "cert_pem": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateInput": { + "type": "object", + "properties": { + "cert_pem": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "tls.DNSAccountInput": { + "type": "object", + "properties": { + "authorization": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "updater.Status": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, "user.AccessToken": { "type": "object", "properties": { @@ -8057,17 +20885,6 @@ } } }, - "user.sendEmailCodeRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - } - } - }, "user.updateProfileRequest": { "type": "object", "properties": { @@ -8093,6 +20910,506 @@ "type": "string" } } + }, + "waf.CreateRuleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "waf.IDsRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestResult": { + "type": "object", + "properties": { + "lookback": { + "type": "string" + }, + "matched_count": { + "type": "integer" + }, + "matched_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "rule_count": { + "type": "integer" + }, + "tested_at": { + "type": "string" + } + } + }, + "waf.IPGroupExtIPView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, + "waf.IPGroupInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "waf.IPGroupSyncResult": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/waf.IPGroupView" + }, + "ip_count": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "synced_at": { + "type": "string" + } + } + }, + "waf.IPGroupView": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ext_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupExtIPView" + } + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "last_sync_message": { + "type": "string" + }, + "last_sync_status": { + "type": "string" + }, + "last_synced_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "referenced_by_rule_count": { + "type": "integer" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.RuleEdge": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "source_handle": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "waf.RuleGraph": { + "type": "object", + "properties": { + "edges": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleEdge" + } + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleNode" + } + }, + "schema_version": { + "type": "integer" + } + } + }, + "waf.RuleNode": { + "type": "object", + "properties": { + "config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "position": { + "$ref": "#/definitions/waf.RulePosition" + }, + "type": { + "$ref": "#/definitions/waf.RuleNodeType" + } + } + }, + "waf.RuleNodeType": { + "type": "string", + "enum": [ + "start", + "allow", + "block", + "ip_match", + "geo_match", + "pow", + "ua_check", + "security_check" + ], + "x-enum-varnames": [ + "RuleNodeStart", + "RuleNodeAllow", + "RuleNodeBlock", + "RuleNodeIPMatch", + "RuleNodeGeoMatch", + "RuleNodePoW", + "RuleNodeUACheck", + "RuleNodeSecurityCheck" + ] + }, + "waf.RulePosition": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "waf.RuleView": { + "type": "object", + "properties": { + "applied_site_count": { + "type": "integer" + }, + "applied_site_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "id": { + "type": "integer" + }, + "is_global": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.SaveRuleGraphInput": { + "type": "object", + "properties": { + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "revision": { + "type": "integer" + } + } + }, + "waf.SiteRuleGroupsView": { + "type": "object", + "properties": { + "applied_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "applied_rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + }, + "global_rule_group": { + "$ref": "#/definitions/waf.RuleView" + }, + "route_id": { + "type": "integer" + }, + "rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + }, + "waf.UpdateRuleMetaInput": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "zone.DomainInput": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, + "zone.Input": { + "type": "object", + "properties": { + "domain": { + "type": "string" + } + } + }, + "zone.ListItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domain_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "zone.Overview": { + "type": "object", + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ZoneDomain" + } + }, + "zone": { + "$ref": "#/definitions/model.Zone" + } + } + }, + "zone.Stats": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "bucket_minutes": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, + "domain_count": { + "type": "integer" + }, + "range": { + "$ref": "#/definitions/zone.StatsRange" + }, + "range_hours": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "series": { + "type": "array", + "items": { + "$ref": "#/definitions/zone.StatsPoint" + } + }, + "unique_visitors": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "zone.StatsPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "bytes_sent": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitors": { + "type": "integer" + } + } + }, + "zone.StatsRange": { + "type": "string", + "enum": [ + "24h", + "7d", + "30d" + ], + "x-enum-varnames": [ + "StatsRange24h", + "StatsRange7d", + "StatsRange30d" + ] } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index d895e98e..18668c08 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,461 @@ basePath: / definitions: + Wavelet_openflare_share_protocol.ActiveConfigMeta: + properties: + checksum: + type: string + version: + type: string + type: object + Wavelet_openflare_share_protocol.BufferedObservabilityRecord: + properties: + access_logs: + items: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeAccessLog' + type: array + captured_at_unix: + type: integer + edge_health: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeEdgeHealth' + host_metrics: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeMetricSnapshot' + type: object + Wavelet_openflare_share_protocol.NodeAccessLog: + properties: + bytes_sent: + description: body bytes = 已提供数据 + type: integer + cache_status: + description: $upstream_cache_status + type: string + host: + type: string + logged_at_unix: + type: integer + path: + type: string + remote_addr: + type: string + request_length: + description: 接收数据 + type: integer + request_time_ms: + description: optional + type: integer + status_code: + type: integer + user_agent: + type: string + type: object + Wavelet_openflare_share_protocol.NodeEdgeHealth: + properties: + captured_at_unix: + type: integer + connections: + type: integer + message: + type: string + status: + type: string + type: object + Wavelet_openflare_share_protocol.NodeHealthEvent: + properties: + event_type: + type: string + message: + type: string + metadata: + additionalProperties: + type: string + type: object + severity: + type: string + triggered_at_unix: + type: integer + type: object + Wavelet_openflare_share_protocol.NodeMetricSnapshot: + properties: + captured_at_unix: + type: integer + cpu_usage_percent: + type: number + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + Wavelet_openflare_share_protocol.NodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + hostname: + type: string + kernel_version: + type: string + os_name: + type: string + os_version: + type: string + reported_at_unix: + type: integer + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + uptime_seconds: + type: integer + type: object + Wavelet_openflare_share_protocol.PagesDeploymentHashResponse: + properties: + deployment_id: + type: integer + hash: + type: string + type: object + Wavelet_openflare_share_protocol.PagesProjectLatestHashResponse: + properties: + deployment_id: + type: integer + file_count: + type: integer + hash: + type: string + package_size: + type: integer + project_id: + type: integer + total_size: + type: integer + type: object + Wavelet_openflare_share_protocol.WAFIPGroup: + properties: + checksum: + type: string + enabled: + type: boolean + id: + type: integer + ip_list: + items: + type: string + type: array + name: + type: string + type: + type: string + type: object + Wavelet_plugins_domain_admin_model.Schedule: + properties: + created_at: + type: string + cron: + type: string + id: + example: "0" + type: string + is_active: + type: boolean + name: + type: string + payload: + type: string + task_type: + type: string + updated_at: + type: string + type: object + Wavelet_plugins_domain_admin_model.SystemConfig: + properties: + created_at: + type: string + description: + type: string + key: + type: string + type: + type: string + updated_at: + type: string + value: + type: string + visibility: + type: integer + type: object + Wavelet_plugins_domain_admin_model.TaskExecution: + properties: + created_at: + type: string + duration: + type: integer + error_message: + type: string + finished_at: + type: string + id: + example: "0" + type: string + log: + type: string + max_retry: + type: integer + payload: + type: string + result: + type: string + retry_count: + type: integer + retryable: + type: boolean + started_at: + type: string + status: + $ref: '#/definitions/model.TaskExecutionStatus' + task_id: + type: string + task_name: + type: string + task_type: + type: string + triggered_by: + type: string + updated_at: + type: string + type: object + Wavelet_plugins_domain_admin_model.Template: + properties: + content: + type: string + created_at: + type: string + description: + type: string + id: + type: integer + is_system: + type: boolean + key: + type: string + name: + type: string + subject: + type: string + type: + type: string + updated_at: + type: string + type: object + agent.ActiveConfigMeta: + properties: + checksum: + type: string + version: + type: string + type: object + agent.ApplyLogPayload: + properties: + checksum: + type: string + main_config_checksum: + type: string + message: + type: string + node_id: + type: string + result: + type: string + route_config_checksum: + type: string + support_file_count: + type: integer + version: + type: string + type: object + agent.ConfigResponse: + properties: + checksum: + type: string + created_at: + type: string + source_config_json: + type: string + support_files: + items: + $ref: '#/definitions/agent.SupportFile' + type: array + version: + type: string + type: object + agent.HeartbeatResponse: + properties: + active_config: + $ref: '#/definitions/agent.ActiveConfigMeta' + agent_settings: + $ref: '#/definitions/agent.Settings' + node: + $ref: '#/definitions/model.OpenFlareNode' + waf_ip_groups: + items: + $ref: '#/definitions/agent.WAFIPGroup' + type: array + type: object + agent.NodePayload: + properties: + access_logs: + items: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeAccessLog' + type: array + buffered: + items: + $ref: '#/definitions/Wavelet_openflare_share_protocol.BufferedObservabilityRecord' + type: array + current_version: + type: string + edge_health: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeEdgeHealth' + ext_version: + type: string + health_events: + items: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeHealthEvent' + type: array + host_metrics: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeMetricSnapshot' + ip: + type: string + last_error: + type: string + name: + type: string + node_id: + type: string + openresty_message: + type: string + openresty_status: + type: string + profile: + $ref: '#/definitions/Wavelet_openflare_share_protocol.NodeSystemProfile' + schema_version: + type: integer + version: + type: string + waf_ip_group_checksums: + additionalProperties: + type: string + type: object + type: object + agent.RegistrationResponse: + properties: + access_token: + type: string + name: + type: string + node_id: + type: string + type: object + agent.Settings: + properties: + auto_update: + type: boolean + heartbeat_interval: + type: integer + restart_openresty_now: + type: boolean + update_channel: + type: string + update_now: + type: boolean + update_repo: + type: string + update_tag: + type: string + websocket_upgrade_enabled: + type: boolean + type: object + agent.SupportFile: + properties: + content: + type: string + path: + type: string + type: object + agent.WAFIPGroup: + properties: + checksum: + type: string + enabled: + type: boolean + id: + type: integer + ip_list: + items: + type: string + type: array + name: + type: string + type: + type: string + type: object + agent.WAFIPGroupSyncInput: + properties: + checksums: + additionalProperties: + type: string + type: object + ids: + items: + type: integer + type: array + type: object + agent.WAFIPGroupSyncResult: + properties: + groups: + items: + $ref: '#/definitions/Wavelet_openflare_share_protocol.WAFIPGroup' + type: array + type: object + apply_log.CleanupInput: + properties: + delete_all: + type: boolean + retention_days: + type: integer + type: object + apply_log.CleanupResult: + properties: + cutoff: + type: string + delete_all: + type: boolean + deleted_count: + type: integer + retention_days: + type: integer + type: object + apply_log.ListResult: + properties: + current: + type: integer + rows: + items: + $ref: '#/definitions/model.OpenFlareApplyLog' + type: array + total: + type: integer + totalPage: + type: integer + type: object auth.AuthSourceView: properties: client_secret_configured: @@ -113,6 +569,244 @@ definitions: - solutions - token type: object + cloudflare.AvailableDomain: + properties: + domain: + type: string + id: + type: integer + zone_domain: + type: string + zone_id: + type: integer + type: object + cloudflare.ConnectionInput: + properties: + api_token: + type: string + dns_account_id: + type: integer + source: + type: string + type: object + cloudflare.ConnectionView: + properties: + configured: + type: boolean + dns_account_id: + type: integer + ready: + type: boolean + source: + type: string + status: + type: string + verified_at: + type: string + type: object + cloudflare.GroupDetail: + properties: + group: + $ref: '#/definitions/cloudflare.GroupItem' + members: + items: + $ref: '#/definitions/cloudflare.MemberItem' + type: array + type: object + cloudflare.GroupInput: + properties: + backup_node_id: + type: integer + default_proxied: + type: boolean + enabled: + type: boolean + name: + type: string + primary_node_id: + type: integer + type: object + cloudflare.GroupItem: + properties: + active_node: + $ref: '#/definitions/cloudflare.NodeOption' + backup_node: + $ref: '#/definitions/cloudflare.NodeOption' + created_at: + type: string + default_proxied: + type: boolean + enabled: + type: boolean + id: + type: integer + member_count: + type: integer + name: + type: string + primary_node: + $ref: '#/definitions/cloudflare.NodeOption' + updated_at: + type: string + type: object + cloudflare.MemberCreateInput: + properties: + proxied: + type: boolean + zone_domain_id: + type: integer + type: object + cloudflare.MemberItem: + properties: + desired_ip: + type: string + domain: + type: string + group_id: + type: integer + id: + type: integer + last_error: + type: string + proxied: + type: boolean + sync_status: + type: string + synced_at: + type: string + zone_domain_id: + type: integer + zone_id: + type: integer + type: object + cloudflare.MemberUpdateInput: + properties: + proxied: + type: boolean + type: object + cloudflare.NodeOption: + properties: + id: + type: integer + ip: + type: string + name: + type: string + type: object + cloudflare.Overview: + properties: + connection: + $ref: '#/definitions/cloudflare.ConnectionView' + error_count: + type: integer + group_count: + type: integer + member_count: + type: integer + ok_count: + type: integer + pending_count: + type: integer + type: object + cloudflare.SyncReceipt: + properties: + task_id: + type: string + type: object + config_version.CleanupInput: + properties: + keep_count: + type: integer + type: object + config_version.CleanupResult: + properties: + deleted_count: + type: integer + message: + type: string + type: object + config_version.ConfigDiffResult: + properties: + active_version: + type: string + active_website_count: + type: integer + added_domains: + items: + type: string + type: array + added_sites: + items: + type: string + type: array + changed_option_details: + items: + $ref: '#/definitions/config_version.ConfigOptionDiffItem' + type: array + changed_option_keys: + items: + type: string + type: array + current_website_count: + type: integer + main_config_changed: + type: boolean + modified_domains: + items: + type: string + type: array + modified_sites: + items: + type: string + type: array + removed_domains: + items: + type: string + type: array + removed_sites: + items: + type: string + type: array + waf_config_changed: + type: boolean + type: object + config_version.ConfigOptionDiffItem: + properties: + current_value: + type: string + key: + type: string + previous_value: + type: string + type: object + config_version.ConfigPreviewResult: + properties: + checksum: + type: string + main_config: + type: string + rendered_config: + type: string + route_config: + type: string + route_count: + type: integer + snapshot_json: + type: string + support_files: + items: + $ref: '#/definitions/config_version.SupportFile' + type: array + website_count: + type: integer + type: object + config_version.SupportFile: + properties: + content: + type: string + path: + type: string + type: object contracts.AuthSourceDTO: properties: client_id: @@ -188,6 +882,105 @@ definitions: type: type: string type: object + dashboard.Capacity: + properties: + average_cpu_usage_percent: + type: number + average_memory_usage_percent: + type: number + high_cpu_nodes: + type: integer + high_memory_nodes: + type: integer + high_storage_nodes: + type: integer + type: object + dashboard.OverviewPayload: + properties: + capacity: + $ref: '#/definitions/dashboard.Capacity' + distributions: + $ref: '#/definitions/dashboard.distributionsPayload' + generated_at: {} + nodes: + items: + items: {} + type: array + type: array + summary: + $ref: '#/definitions/dashboard.Summary' + traffic: + $ref: '#/definitions/dashboard.Traffic' + trends: + $ref: '#/definitions/dashboard.trendsPayload' + type: object + dashboard.Summary: + properties: + offline_nodes: + type: integer + online_nodes: + type: integer + pending_nodes: + type: integer + total_nodes: + type: integer + unhealthy_nodes: + type: integer + type: object + dashboard.Traffic: + properties: + error_count: + type: integer + estimated_qps: + type: number + reported_nodes: + type: integer + request_count: + type: integer + unique_visitors: + type: integer + type: object + dashboard.distributionsPayload: + properties: + source_countries: + items: + items: {} + type: array + type: array + status_codes: + items: + items: {} + type: array + type: array + top_domains: + items: + items: {} + type: array + type: array + type: object + dashboard.trendsPayload: + properties: + capacity_24h: + items: + items: {} + type: array + type: array + disk_io_24h: + items: + items: {} + type: array + type: array + network_24h: + items: + items: {} + type: array + type: array + traffic_24h: + items: + items: {} + type: array + type: array + type: object disk.Status: properties: base_path: @@ -203,6 +996,66 @@ definitions: ttl_minutes: type: integer type: object + flared.ApplyLogPayload: + properties: + checksum: + type: string + main_config_checksum: + type: string + message: + type: string + node_id: + type: string + result: + type: string + route_config_checksum: + type: string + support_file_count: + type: integer + version: + type: string + type: object + flared.HeartbeatPayload: + properties: + client_version: + type: string + connected_relays: + items: + $ref: '#/definitions/protocol.FlaredConnectedRelay' + type: array + current_checksum: + type: string + current_version: + type: string + frp_version: + type: string + ip: + type: string + tunnel_status: + type: string + type: object + flared.HeartbeatResponse: + properties: + active_config: + $ref: '#/definitions/Wavelet_openflare_share_protocol.ActiveConfigMeta' + tunnel_settings: + $ref: '#/definitions/protocol.RelaySettings' + type: object + flared.TunnelConfigResponse: + properties: + checksum: + type: string + proxies: + items: + $ref: '#/definitions/protocol.FlaredProxyEntry' + type: array + relays: + items: + $ref: '#/definitions/protocol.FlaredRelayInfo' + type: array + version: + type: string + type: object handler.batchDownloadRequest: properties: ids: @@ -336,6 +1189,19 @@ definitions: total: type: integer type: object + model.AcmeAccount: + properties: + created_at: + type: string + email: + type: string + id: + type: integer + updated_at: + type: string + url: + type: string + type: object model.BindRequest: properties: channel_id: @@ -395,6 +1261,44 @@ definitions: type: type: string type: object + model.ConfigVersion: + properties: + checksum: + type: string + created_at: + type: string + created_by: + type: string + id: + type: string + is_active: + type: boolean + main_config: + type: string + rendered_config: + type: string + snapshot_json: + type: string + support_files_json: + type: string + version: + type: string + type: object + model.ConfigVersionSummary: + properties: + checksum: + type: string + created_at: + type: string + created_by: + type: string + id: + type: string + is_active: + type: boolean + version: + type: string + type: object model.CreateChannelRequest: properties: credentials: @@ -559,6 +1463,19 @@ definitions: version: type: string type: object + model.DNSAccount: + properties: + created_at: + type: string + id: + type: integer + name: + type: string + type: + type: string + updated_at: + type: string + type: object model.DatabaseInfoResponse: properties: name: @@ -678,6 +1595,167 @@ definitions: description: 用于加载更早日志的 cursor type: integer type: object + model.OpenFlareApplyLog: + properties: + checksum: + type: string + created_at: + type: string + id: + type: integer + main_config_checksum: + type: string + message: + type: string + node_id: + type: string + result: + type: string + route_config_checksum: + type: string + support_file_count: + type: integer + version: + type: string + type: object + model.OpenFlareHealthEvent: + properties: + created_at: + type: string + event_type: + type: string + first_triggered_at: + type: string + id: + type: integer + last_triggered_at: + type: string + message: + type: string + metadata_json: + type: string + node_id: + type: string + reported_at: + type: string + resolved_at: + type: string + severity: + type: string + status: + type: string + updated_at: + type: string + type: object + model.OpenFlareNode: + properties: + auto_update_enabled: + type: boolean + capabilities_json: + type: string + created_at: + type: string + current_version: + type: string + ext_version: + type: string + geo_latitude: + type: number + geo_longitude: + type: number + geo_manual_override: + type: boolean + geo_name: + type: string + id: + type: integer + ip: + type: string + ip_manual_override: + type: boolean + last_error: + type: string + last_seen_at: + type: string + name: + type: string + node_id: + type: string + node_type: + type: string + openresty_message: + type: string + openresty_status: + type: string + relay_agent_access_addr: + type: string + relay_bind_port: + type: integer + relay_client_access_addr: + type: string + relay_client_proxy_url: + type: string + relay_status: + type: string + relay_vhost_http_port: + type: integer + relay_web_server_enabled: + type: boolean + restart_openresty_requested: + type: boolean + status: + type: string + update_channel: + type: string + update_requested: + type: boolean + update_tag: + type: string + updated_at: + type: string + version: + type: string + type: object + model.OpenFlareNodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + created_at: + type: string + hostname: + type: string + id: + type: integer + kernel_version: + type: string + node_id: + type: string + os_name: + type: string + os_version: + type: string + reported_at: + type: string + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + updated_at: + type: string + uptime_seconds: + type: integer + type: object + model.OpenFlareOption: + properties: + key: + type: string + value: + type: string + type: object model.PublicChannelDTO: properties: id: @@ -738,43 +1816,6 @@ definitions: updated_at: type: string type: object - model.Schedule: - properties: - created_at: - type: string - cron: - type: string - id: - example: "0" - type: string - is_active: - type: boolean - name: - type: string - payload: - type: string - task_type: - type: string - updated_at: - type: string - type: object - model.SystemConfig: - properties: - created_at: - type: string - description: - type: string - key: - type: string - type: - type: string - updated_at: - type: string - value: - type: string - visibility: - type: integer - type: object model.SystemStatusResponse: properties: alloc: @@ -834,43 +1875,46 @@ definitions: uptime: type: string type: object - model.TaskExecution: + model.TLSCertificate: properties: + acme_account_id: + type: integer + apply_message: + type: string + apply_status: + type: string + auto_renew: + type: boolean created_at: type: string - duration: + disable_cname: + type: boolean + dns_account_id: type: integer - error_message: + dns1: type: string - finished_at: + dns2: type: string id: - example: "0" - type: string - log: - type: string - max_retry: type: integer - payload: + key_algorithm: type: string - result: + name: type: string - retry_count: - type: integer - retryable: + not_after: + type: string + not_before: + type: string + other_domains: + type: string + primary_domain: + type: string + provider: + type: string + remark: + type: string + skip_dns: type: boolean - started_at: - type: string - status: - $ref: '#/definitions/model.TaskExecutionStatus' - task_id: - type: string - task_name: - type: string - task_type: - type: string - triggered_by: - type: string updated_at: type: string type: object @@ -886,29 +1930,6 @@ definitions: - TaskExecutionStatusRunning - TaskExecutionStatusSucceeded - TaskExecutionStatusFailed - model.Template: - properties: - content: - type: string - created_at: - type: string - description: - type: string - id: - type: integer - is_system: - type: boolean - key: - type: string - name: - type: string - subject: - type: string - type: - type: string - updated_at: - type: string - type: object model.TestPushChannelRequest: properties: name: @@ -1185,6 +2206,34 @@ definitions: website: type: string type: object + model.Zone: + properties: + created_at: + type: string + domain: + type: string + id: + type: integer + updated_at: + type: string + type: object + model.ZoneDomain: + properties: + cert_id: + type: integer + created_at: + type: string + domain: + type: string + id: + type: integer + proxy_route_id: + type: integer + updated_at: + type: string + zone_id: + type: integer + type: object models.Upload: properties: access_mode: @@ -1256,6 +2305,1358 @@ definitions: - UploadStatusPending - UploadStatusUsed - UploadStatusDeleted + node.AgentReleaseInfo: + properties: + body: + type: string + channel: + type: string + current_version: + type: string + has_update: + type: boolean + html_url: + type: string + prerelease: + type: boolean + published_at: + type: string + requested_channel: + type: string + requested_tag: + type: string + tag_name: + type: string + update_requested: + type: boolean + type: object + node.AgentUpdateInput: + properties: + channel: + type: string + tag_name: + type: string + type: object + node.BootstrapView: + properties: + discovery_token: + type: string + type: object + node.HealthEventCleanupResult: + properties: + deleted_count: + type: integer + node_id: + type: string + type: object + node.Input: + properties: + auto_update_enabled: + type: boolean + geo_latitude: + type: number + geo_longitude: + type: number + geo_manual_override: + type: boolean + geo_name: + type: string + ip: + type: string + ip_manual_override: + type: boolean + name: + type: string + node_type: + type: string + relay_agent_access_addr: + type: string + relay_bind_port: + type: integer + relay_client_access_addr: + type: string + relay_client_proxy_url: + type: string + relay_vhost_http_port: + type: integer + relay_web_server_enabled: + type: boolean + type: object + node.ObservabilityView: + properties: + analytics: + $ref: '#/definitions/observability.NodeAnalytics' + health_events: + items: + $ref: '#/definitions/model.OpenFlareHealthEvent' + type: array + metric_snapshots: + items: + $ref: '#/definitions/observability.NodeMetricSnapshotView' + type: array + node_id: + type: string + profile: + $ref: '#/definitions/model.OpenFlareNodeSystemProfile' + relay_dashboard: + $ref: '#/definitions/observability.RelayDashboardSnapshot' + trends: + $ref: '#/definitions/observability.NodeTrends' + type: object + node.View: + properties: + access_token: + type: string + auto_update_enabled: + type: boolean + created_at: + type: string + current_version: + type: string + ext_version: + type: string + geo_latitude: + type: number + geo_longitude: + type: number + geo_manual_override: + type: boolean + geo_name: + type: string + id: + type: integer + ip: + type: string + ip_manual_override: + type: boolean + last_error: + type: string + last_seen_at: {} + latest_apply_at: + type: string + latest_apply_checksum: + type: string + latest_apply_message: + type: string + latest_apply_result: + type: string + latest_main_config_checksum: + type: string + latest_route_config_checksum: + type: string + latest_support_file_count: + type: integer + name: + type: string + node_id: + type: string + node_type: + type: string + openresty_message: + type: string + openresty_status: + type: string + relay_agent_access_addr: + type: string + relay_bind_port: + type: integer + relay_client_access_addr: + type: string + relay_client_proxy_url: + type: string + relay_status: + type: string + relay_vhost_http_port: + type: integer + relay_web_server_enabled: + type: boolean + restart_openresty_requested: + type: boolean + status: + type: string + update_channel: + type: string + update_requested: + type: boolean + update_tag: + type: string + updated_at: + type: string + version: + type: string + type: object + observability.AccessLogCleanupInput: + properties: + retention_days: + type: integer + type: object + observability.AccessLogCleanupResult: + properties: + cutoff: + type: string + deleted_count: + type: integer + retention_days: + type: integer + type: object + observability.AccessLogIPAnalysisSummary: + properties: + bandwidth_served: + type: integer + bytes_received: + type: integer + error_count: + type: integer + total_requests: + type: integer + unique_hosts: + type: integer + unique_paths: + type: integer + type: object + observability.AccessLogIPAnalysisView: + properties: + device_types: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + generated_at: + type: string + hours: + type: integer + remote_addr: + type: string + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + summary: + $ref: '#/definitions/observability.AccessLogIPAnalysisSummary' + top_browsers: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_hosts: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_paths: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_user_agents: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + type: object + observability.AccessLogIPSummaryList: + properties: + has_more: + type: boolean + hours: + type: integer + items: + items: + $ref: '#/definitions/observability.AccessLogIPSummaryView' + type: array + page: + type: integer + page_size: + type: integer + since: + type: string + sort_by: + type: string + sort_order: + type: string + total_ip: + type: integer + until: + type: string + type: object + observability.AccessLogIPSummaryView: + properties: + bytes_received: + type: integer + bytes_sent: + type: integer + last_seen_at: + type: string + recent_requests: + description: RecentRequests is deprecated and always 0. + type: integer + region: + type: string + remote_addr: + type: string + success_2xx_count: + type: integer + success_ratio: + type: number + total_requests: + type: integer + type: object + observability.AccessLogIPTrendPoint: + properties: + bucket_started_at: + type: string + request_count: + type: integer + type: object + observability.AccessLogIPTrendView: + properties: + bucket_minutes: + type: integer + hours: + type: integer + points: + items: + $ref: '#/definitions/observability.AccessLogIPTrendPoint' + type: array + remote_addr: + type: string + type: object + observability.AccessLogList: + properties: + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.AccessLogView' + type: array + page: + type: integer + page_size: + type: integer + total_ip: + type: integer + total_record: + type: integer + type: object + observability.AccessLogOverview: + properties: + bucket_minutes: + type: integer + device_types: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + generated_at: + type: string + hours: + type: integer + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + summary: + $ref: '#/definitions/observability.AccessLogOverviewSummary' + top_browsers: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_hosts: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_ips: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_operating_systems: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_paths: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_user_agents: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + trends: + $ref: '#/definitions/observability.AccessLogOverviewTrends' + type: object + observability.AccessLogOverviewMetricPoint: + properties: + bucket_started_at: + type: string + value: + type: integer + type: object + observability.AccessLogOverviewSummary: + properties: + bandwidth_served: + type: integer + total_requests: + type: integer + total_visits: + type: integer + type: object + observability.AccessLogOverviewTrends: + properties: + bandwidth: + items: + $ref: '#/definitions/observability.AccessLogOverviewMetricPoint' + type: array + requests: + items: + $ref: '#/definitions/observability.AccessLogOverviewMetricPoint' + type: array + visits: + items: + $ref: '#/definitions/observability.AccessLogOverviewMetricPoint' + type: array + type: object + observability.AccessLogView: + properties: + bytes_sent: + type: integer + cache_status: + type: string + created_at: + type: string + host: + type: string + id: + type: string + logged_at: + type: string + node_id: + type: string + node_name: + type: string + path: + type: string + region: + type: string + remote_addr: + type: string + request_length: + type: integer + request_time_ms: + type: integer + status_code: + type: integer + user_agent: + type: string + type: object + observability.CapacityTrendPoint: + properties: + average_cpu_usage_percent: + type: number + average_memory_usage_percent: + type: number + bucket_started_at: + type: string + reported_nodes: + type: integer + type: object + observability.DiskIOTrendPoint: + properties: + bucket_started_at: + type: string + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + reported_nodes: + type: integer + type: object + observability.DistributionItem: + properties: + key: + type: string + value: + type: integer + type: object + observability.FoldedAccessLogIPList: + properties: + bucket_started_at: + type: string + fold_minutes: + type: integer + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.FoldedAccessLogIPView' + type: array + page: + type: integer + page_size: + type: integer + sort_by: + type: string + sort_order: + type: string + total_ip: + type: integer + type: object + observability.FoldedAccessLogIPView: + properties: + client_error_count: + type: integer + last_seen_at: + type: string + remote_addr: + type: string + request_count: + type: integer + server_error_count: + type: integer + success_count: + type: integer + type: object + observability.FoldedAccessLogList: + properties: + fold_minutes: + type: integer + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.FoldedAccessLogView' + type: array + page: + type: integer + page_size: + type: integer + total_bucket: + type: integer + total_ip: + type: integer + total_record: + type: integer + type: object + observability.FoldedAccessLogView: + properties: + bucket_started_at: + type: string + client_error_count: + type: integer + request_count: + type: integer + server_error_count: + type: integer + success_count: + type: integer + unique_host_count: + type: integer + unique_ip_count: + type: integer + type: object + observability.HealthSummary: + properties: + active_alerts: + type: integer + critical_alerts: + type: integer + has_capacity_risk: + type: boolean + has_runtime_risk: + type: boolean + has_traffic_risk: + type: boolean + info_alerts: + type: integer + resolved_alerts: + type: integer + warning_alerts: + type: integer + type: object + observability.NetworkTrendPoint: + properties: + bucket_started_at: + type: string + bytes_provided: + description: sum(bytes_sent) + type: integer + bytes_received: + description: sum(request_length) + type: integer + reported_nodes: + type: integer + type: object + observability.NodeAnalytics: + properties: + distributions: + $ref: '#/definitions/observability.TrafficDistributions' + health: + $ref: '#/definitions/observability.HealthSummary' + traffic: + $ref: '#/definitions/observability.TrafficWindowSummary' + type: object + observability.NodeMetricSnapshotView: + properties: + captured_at: + type: string + cpu_usage_percent: + type: number + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + id: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + node_id: + type: string + openresty_connections: + type: integer + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + observability.NodeTrends: + properties: + capacity_24h: + items: + $ref: '#/definitions/observability.CapacityTrendPoint' + type: array + disk_io_24h: + items: + $ref: '#/definitions/observability.DiskIOTrendPoint' + type: array + network_24h: + items: + $ref: '#/definitions/observability.NetworkTrendPoint' + type: array + traffic_24h: + items: + $ref: '#/definitions/observability.TrafficTrendPoint' + type: array + type: object + observability.RelayDashboardSnapshot: + properties: + client_counts: + type: integer + offline_proxies: + type: integer + online_proxies: + type: integer + proxies: + items: + $ref: '#/definitions/observability.RelayProxyStat' + type: array + total_connections: + type: integer + total_proxies: + type: integer + type: object + observability.RelayProxyStat: + properties: + client_addr: + type: string + client_version: + type: string + last_close_time: + type: string + last_start_time: + type: string + name: + type: string + status: + type: string + type: + type: string + type: object + observability.TrafficDistributions: + properties: + source_countries: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_domains: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + type: object + observability.TrafficTrendPoint: + properties: + bucket_started_at: + type: string + error_count: + type: integer + request_count: + type: integer + status_2xx_count: + type: integer + status_4xx_count: + type: integer + status_5xx_count: + type: integer + unique_visitor_count: + type: integer + type: object + observability.TrafficWindowSummary: + properties: + error_count: + type: integer + error_rate_percent: + type: number + estimated_qps: + type: number + request_count: + type: integer + unique_visitor_count: + type: integer + window_ended_at: + type: string + window_started_at: + type: string + type: object + option.geoIPLookupRequest: + properties: + ip: + type: string + provider: + type: string + type: object + option.geoIPLookupView: + properties: + ip: + type: string + iso_code: + type: string + latitude: + type: number + longitude: + type: number + name: + type: string + provider: + type: string + type: object + option.optionBatchPayload: + properties: + options: + items: + $ref: '#/definitions/model.OpenFlareOption' + type: array + type: object + option.publicAuthSourceView: + properties: + authorize_url: + type: string + display_name: + type: string + icon_url: + type: string + id: + type: integer + name: + type: string + type: + type: string + type: object + option.statusView: + properties: + auth_sources: + items: + $ref: '#/definitions/option.publicAuthSourceView' + type: array + cap_login_enabled: + type: boolean + email_verification: + type: boolean + password_register_enabled: + type: boolean + server_address: + type: string + start_time: + type: integer + version: + type: string + type: object + origin.DetailView: + properties: + address: + type: string + created_at: + type: string + id: + type: integer + name: + type: string + remark: + type: string + route_count: + type: integer + routes: + items: + $ref: '#/definitions/origin.RouteSummary' + type: array + updated_at: + type: string + type: object + origin.Input: + properties: + address: + type: string + name: + type: string + remark: + type: string + type: object + origin.RouteSummary: + properties: + domain: + type: string + enabled: + type: boolean + id: + type: integer + origin_url: + type: string + updated_at: + type: string + type: object + origin.View: + properties: + address: + type: string + created_at: + type: string + id: + type: integer + name: + type: string + remark: + type: string + route_count: + type: integer + updated_at: + type: string + type: object + pages.DeploymentFileView: + properties: + checksum: + type: string + created_at: + type: string + deployment_id: + type: integer + id: + type: integer + path: + type: string + size: + type: integer + type: object + pages.DeploymentView: + properties: + activated_at: + type: string + checksum: + type: string + created_at: + type: string + created_by: + type: string + deployment_number: + type: integer + file_count: + type: integer + id: + type: integer + project_id: + type: integer + source_label: + type: string + source_type: + type: string + status: + type: string + total_size: + type: integer + trigger_type: + type: string + upload_id: + example: "0" + type: string + type: object + pages.Input: + properties: + api_proxy_enabled: + type: boolean + api_proxy_pass: + type: string + api_proxy_path: + type: string + api_proxy_rewrite: + type: string + description: + type: string + enabled: + type: boolean + entry_file: + type: string + name: + type: string + root_dir: + type: string + slug: + type: string + spa_fallback_enabled: + type: boolean + spa_fallback_path: + type: string + type: object + pages.SourceActionReceipt: + properties: + action: + type: string + execution_id: + type: string + task_id: + type: string + type: object + pages.SourceRevisionView: + properties: + asset_name: + type: string + label: + type: string + revision: + type: string + type: object + pages.SourceSyncInput: + properties: + confirmed_revision: + type: string + type: object + pages.SourceUpdateInput: + properties: + allow_insecure: + type: boolean + asset_name: + type: string + auto_update_enabled: + type: boolean + check_interval_minutes: + type: integer + release_selector: + type: string + release_tag: + type: string + remote_url: + type: string + repository_url: + type: string + source_type: + type: string + type: object + pages.SourceUpdateResult: + properties: + check_task: + $ref: '#/definitions/pages.SourceActionReceipt' + source: + $ref: '#/definitions/pages.SourceView' + warning: + type: string + type: object + pages.SourceView: + properties: + allow_insecure: + type: boolean + asset_name: + type: string + auto_update_enabled: + type: boolean + check_interval_minutes: + type: integer + github_repository: + type: string + last_applied: + $ref: '#/definitions/pages.SourceRevisionView' + last_checked_at: + type: string + last_error: + type: string + last_seen: + $ref: '#/definitions/pages.SourceRevisionView' + last_synced_at: + type: string + next_check_at: + type: string + release_selector: + type: string + release_tag: + type: string + remote_url: + type: string + source_type: + type: string + sync_status: + type: string + update_available: + type: boolean + type: object + pages.UploadFromURLInput: + properties: + url: + type: string + type: object + pages.View: + properties: + active_deployment: + $ref: '#/definitions/pages.DeploymentView' + active_deployment_id: + type: integer + api_proxy_enabled: + type: boolean + api_proxy_pass: + type: string + api_proxy_path: + type: string + api_proxy_rewrite: + type: string + created_at: + type: string + deployment_count: + type: integer + description: + type: string + enabled: + type: boolean + entry_file: + type: string + id: + type: integer + name: + type: string + root_dir: + type: string + slug: + type: string + spa_fallback_enabled: + type: boolean + spa_fallback_path: + type: string + updated_at: + type: string + type: object + protocol.AgentNodeHealthEvent: + properties: + event_type: + type: string + message: + type: string + metadata: + additionalProperties: + type: string + type: object + severity: + type: string + triggered_at_unix: + type: integer + type: object + protocol.AgentNodeMetricSnapshot: + properties: + captured_at_unix: + type: integer + cpu_usage_percent: + type: number + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + protocol.AgentNodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + hostname: + type: string + kernel_version: + type: string + os_name: + type: string + os_version: + type: string + reported_at_unix: + type: integer + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + uptime_seconds: + type: integer + type: object + protocol.FlaredConnectedRelay: + properties: + proxy_count: + type: integer + relay_node_id: + type: string + status: + type: string + type: object + protocol.FlaredProxyEntry: + properties: + custom_domains: + items: + type: string + type: array + local_addr: + type: string + local_port: + type: integer + name: + type: string + type: + type: string + type: object + protocol.FlaredRelayInfo: + properties: + address: + type: string + auth_token: + type: string + proxy_url: + type: string + relay_node_id: + type: string + type: object + protocol.RelayConfig: + properties: + auth_token: + type: string + bind_port: + type: integer + log_level: + type: string + vhost_http_port: + type: integer + web_server_enabled: + type: boolean + web_server_port: + type: integer + type: object + protocol.RelayProxyStat: + properties: + client_addr: + type: string + client_version: + type: string + last_close_time: + type: string + last_start_time: + type: string + name: + type: string + status: + type: string + type: + type: string + type: object + protocol.RelaySettings: + properties: + auto_update: + type: boolean + heartbeat_interval: + type: integer + update_channel: + type: string + update_now: + type: boolean + update_repo: + type: string + update_tag: + type: string + websocket_upgrade_enabled: + type: boolean + type: object + proxy_route.CustomHeaderInput: + properties: + key: + type: string + value: + type: string + type: object + proxy_route.Input: + properties: + basic_auth_enabled: + type: boolean + basic_auth_password: + type: string + basic_auth_username: + type: string + cache_enabled: + type: boolean + cache_policy: + type: string + cache_rules: + items: + type: string + type: array + custom_headers: + items: + $ref: '#/definitions/proxy_route.CustomHeaderInput' + type: array + enable_https: + type: boolean + enabled: + type: boolean + limit_conn_per_ip: + type: integer + limit_conn_per_server: + type: integer + limit_rate: + type: string + limit_req_per_ip: + type: string + origin_address: + type: string + origin_host: + type: string + origin_id: + type: integer + origin_port: + type: string + origin_scheme: + type: string + origin_uri: + type: string + origin_url: + type: string + pages_project_id: + type: integer + redirect_http: + type: boolean + site_name: + type: string + tunnel_id: + type: integer + tunnel_node_id: + type: integer + tunnel_target_addr: + type: string + tunnel_target_protocol: + type: string + upstream_type: + type: string + upstreams: + items: + type: string + type: array + zone_domain_ids: + items: + type: integer + type: array + type: object + proxy_route.View: + properties: + basic_auth_enabled: + type: boolean + basic_auth_password: + type: string + basic_auth_username: + type: string + cache_enabled: + type: boolean + cache_policy: + type: string + cache_rule_list: + items: + type: string + type: array + cache_rules: + type: string + created_at: + type: string + custom_header_list: + items: + $ref: '#/definitions/proxy_route.CustomHeaderInput' + type: array + custom_headers: + type: string + enable_https: + type: boolean + enabled: + type: boolean + id: + type: integer + limit_conn_per_ip: + type: integer + limit_conn_per_server: + type: integer + limit_rate: + type: string + limit_req_per_ip: + type: string + origin_host: + type: string + origin_id: + type: integer + origin_url: + type: string + pages_project_id: + type: integer + redirect_http: + type: boolean + site_name: + type: string + tunnel_id: + type: integer + tunnel_node_id: + type: integer + tunnel_target_addr: + type: string + tunnel_target_protocol: + type: string + updated_at: + type: string + upstream_list: + items: + type: string + type: array + upstream_type: + type: string + upstreams: + type: string + zone_domain_ids: + items: + type: integer + type: array + zone_domains: + items: + $ref: '#/definitions/proxy_route.ZoneDomainView' + type: array + type: object + proxy_route.ZoneDomainView: + properties: + cert_id: + type: integer + domain: + type: string + id: + type: integer + zone_id: + type: integer + type: object push.Config: properties: channel: @@ -1275,6 +3676,44 @@ definitions: description: Webhook 地址或 SMTP 地址 type: string type: object + relay.HeartbeatPayload: + properties: + frp_version: + type: string + frps_client_count: + type: integer + frps_connections: + type: integer + frps_proxies: + items: + $ref: '#/definitions/protocol.RelayProxyStat' + type: array + frps_proxy_count: + type: integer + health_events: + items: + $ref: '#/definitions/protocol.AgentNodeHealthEvent' + type: array + ip: + type: string + name: + type: string + profile: + $ref: '#/definitions/protocol.AgentNodeSystemProfile' + relay_status: + type: string + snapshot: + $ref: '#/definitions/protocol.AgentNodeMetricSnapshot' + version: + type: string + type: object + relay.HeartbeatResponse: + properties: + relay_config: + $ref: '#/definitions/protocol.RelayConfig' + relay_settings: + $ref: '#/definitions/protocol.RelaySettings' + type: object response.Any: properties: data: {} @@ -1303,6 +3742,121 @@ definitions: - Second - Minute - Hour + tls.ApplyInput: + properties: + acme_account_id: + type: integer + auto_renew: + type: boolean + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + key_algorithm: + type: string + name: + type: string + other_domains: + type: string + primary_domain: + type: string + remark: + type: string + skip_dns: + type: boolean + type: object + tls.CertificateContent: + properties: + acme_account_id: + type: integer + apply_message: + type: string + apply_status: + type: string + auto_renew: + type: boolean + cert_pem: + type: string + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + id: + type: integer + key_algorithm: + type: string + key_pem: + type: string + name: + type: string + other_domains: + type: string + primary_domain: + type: string + provider: + type: string + remark: + type: string + skip_dns: + type: boolean + type: object + tls.CertificateInput: + properties: + cert_pem: + type: string + key_pem: + type: string + name: + type: string + remark: + type: string + type: object + tls.DNSAccountInput: + properties: + authorization: + type: string + name: + type: string + type: + type: string + type: object + updater.Status: + properties: + asset_name: + type: string + build_time: + type: string + can_upgrade: + type: boolean + current_version: + type: string + latest_version: + type: string + platform: + type: string + prerelease: + type: boolean + published_at: + type: string + release_name: + type: string + release_notes: + type: string + release_url: + type: string + update_available: + type: boolean + upstream_repository: + type: string + type: object user.AccessToken: properties: created_at: @@ -1363,13 +3917,6 @@ definitions: - password - username type: object - user.sendEmailCodeRequest: - properties: - email: - type: string - required: - - email - type: object user.updateProfileRequest: properties: avatar_url: @@ -1387,15 +3934,347 @@ definitions: website: type: string type: object + waf.CreateRuleInput: + properties: + name: + type: string + type: object + waf.IDsRequest: + properties: + ids: + items: + type: integer + type: array + type: object + waf.IPGroupAutoTestInput: + properties: + auto_config: + items: + type: integer + type: array + type: object + waf.IPGroupAutoTestResult: + properties: + lookback: + type: string + matched_count: + type: integer + matched_ips: + items: + type: string + type: array + rule_count: + type: integer + tested_at: + type: string + type: object + waf.IPGroupExtIPView: + properties: + captured_at: + type: string + ip: + type: string + type: object + waf.IPGroupInput: + properties: + auto_config: + items: + type: integer + type: array + enabled: + type: boolean + ip_list: + items: + type: string + type: array + name: + type: string + subscription_format: + type: string + subscription_mapping_rule: + type: string + subscription_url: + type: string + sync_interval_minutes: + type: integer + type: + type: string + type: object + waf.IPGroupSyncResult: + properties: + group: + $ref: '#/definitions/waf.IPGroupView' + ip_count: + type: integer + message: + type: string + next_sync_at: + type: string + status: + type: string + synced_at: + type: string + type: object + waf.IPGroupView: + properties: + auto_config: + items: + type: integer + type: array + created_at: + type: string + enabled: + type: boolean + ext_ips: + items: + $ref: '#/definitions/waf.IPGroupExtIPView' + type: array + id: + type: integer + ip_list: + items: + type: string + type: array + last_sync_message: + type: string + last_sync_status: + type: string + last_synced_at: + type: string + name: + type: string + next_sync_at: + type: string + referenced_by_rule_count: + type: integer + subscription_format: + type: string + subscription_mapping_rule: + type: string + subscription_url: + type: string + sync_interval_minutes: + type: integer + type: + type: string + updated_at: + type: string + type: object + waf.RuleEdge: + properties: + id: + type: string + source: + type: string + source_handle: + type: string + target: + type: string + type: object + waf.RuleGraph: + properties: + edges: + items: + $ref: '#/definitions/waf.RuleEdge' + type: array + nodes: + items: + $ref: '#/definitions/waf.RuleNode' + type: array + schema_version: + type: integer + type: object + waf.RuleNode: + properties: + config: + items: + type: integer + type: array + id: + type: string + label: + type: string + position: + $ref: '#/definitions/waf.RulePosition' + type: + $ref: '#/definitions/waf.RuleNodeType' + type: object + waf.RuleNodeType: + enum: + - start + - allow + - block + - ip_match + - geo_match + - pow + - ua_check + - security_check + type: string + x-enum-varnames: + - RuleNodeStart + - RuleNodeAllow + - RuleNodeBlock + - RuleNodeIPMatch + - RuleNodeGeoMatch + - RuleNodePoW + - RuleNodeUACheck + - RuleNodeSecurityCheck + waf.RulePosition: + properties: + x: + type: number + "y": + type: number + type: object + waf.RuleView: + properties: + applied_site_count: + type: integer + applied_site_ids: + items: + type: integer + type: array + created_at: + type: string + enabled: + type: boolean + graph: + $ref: '#/definitions/waf.RuleGraph' + id: + type: integer + is_global: + type: boolean + name: + type: string + revision: + type: integer + updated_at: + type: string + type: object + waf.SaveRuleGraphInput: + properties: + graph: + $ref: '#/definitions/waf.RuleGraph' + revision: + type: integer + type: object + waf.SiteRuleGroupsView: + properties: + applied_ids: + items: + type: integer + type: array + applied_rule_groups: + items: + $ref: '#/definitions/waf.RuleView' + type: array + global_rule_group: + $ref: '#/definitions/waf.RuleView' + route_id: + type: integer + rule_groups: + items: + $ref: '#/definitions/waf.RuleView' + type: array + type: object + waf.UpdateRuleMetaInput: + properties: + enabled: + type: boolean + name: + type: string + type: object + zone.DomainInput: + properties: + cert_id: + type: integer + domain: + type: string + type: object + zone.Input: + properties: + domain: + type: string + type: object + zone.ListItem: + properties: + created_at: + type: string + domain: + type: string + domain_count: + type: integer + id: + type: integer + updated_at: + type: string + type: object + zone.Overview: + properties: + domains: + items: + $ref: '#/definitions/model.ZoneDomain' + type: array + zone: + $ref: '#/definitions/model.Zone' + type: object + zone.Stats: + properties: + available: + type: boolean + bucket_minutes: + type: integer + bytes_sent: + type: integer + domain_count: + type: integer + range: + $ref: '#/definitions/zone.StatsRange' + range_hours: + type: integer + request_count: + type: integer + series: + items: + $ref: '#/definitions/zone.StatsPoint' + type: array + unique_visitors: + type: integer + window_ended_at: + type: string + window_started_at: + type: string + type: object + zone.StatsPoint: + properties: + bucket_started_at: + type: string + bytes_sent: + type: integer + request_count: + type: integer + unique_visitors: + type: integer + type: object + zone.StatsRange: + enum: + - 24h + - 7d + - 30d + type: string + x-enum-varnames: + - StatsRange24h + - StatsRange7d + - StatsRange30d info: contact: - name: Wavelet - url: https://github.com/Rain-kl/Wavelet - description: Wavelet 平台后端 API,提供用户认证、系统配置、任务调度等通用功能。 + name: OpenFlare + url: https://github.com/Rain-kl/OpenFlare + description: OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。 license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html - title: Wavelet API + title: OpenFlare API version: 1.0.0 paths: /api/v1/admin/auth-sources: @@ -2597,7 +5476,7 @@ paths: - properties: data: items: - $ref: '#/definitions/model.SystemConfig' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.SystemConfig' type: array type: object "401": @@ -2680,7 +5559,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.SystemConfig' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.SystemConfig' type: object "401": description: 未登录 @@ -2900,7 +5779,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.TaskExecution' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.TaskExecution' type: object "400": description: 参数错误 @@ -2983,7 +5862,7 @@ paths: - properties: data: items: - $ref: '#/definitions/model.Schedule' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.Schedule' type: array type: object "401": @@ -3020,7 +5899,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Schedule' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.Schedule' type: object "400": description: Cron 表达式无效、异步任务类型不存在或参数错误 @@ -3111,7 +5990,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Schedule' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.Schedule' type: object "400": description: Cron 表达式无效、参数错误 @@ -3182,7 +6061,7 @@ paths: - properties: data: items: - $ref: '#/definitions/model.Template' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.Template' type: array type: object "401": @@ -3310,7 +6189,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Template' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.Template' type: object "401": description: 未登录 @@ -3359,7 +6238,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Template' + $ref: '#/definitions/Wavelet_plugins_domain_admin_model.Template' type: object "400": description: 参数错误 @@ -4017,6 +6896,321 @@ paths: summary: 更新用户状态 tags: - admin + /api/v1/agent/apply-logs: + post: + consumes: + - application/json + description: 记录 Agent 配置下发与应用结果 + parameters: + - description: 应用日志 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.ApplyLogPayload' + produces: + - application/json + responses: + "200": + description: 日志记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.OpenFlareApplyLog' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 上报配置应用日志 + tags: + - openflare-agent + /api/v1/agent/config-versions/active: + get: + description: 返回当前生效的完整配置包,供 Agent 拉取并应用 + produces: + - application/json + responses: + "200": + description: 活跃配置 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.ConfigResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 获取活跃配置版本 + tags: + - openflare-agent + /api/v1/agent/nodes/heartbeat: + post: + consumes: + - application/json + description: 上报节点状态、指标与健康事件,返回远程控制配置与活跃配置元信息 + parameters: + - description: 心跳数据 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.NodePayload' + produces: + - application/json + responses: + "200": + description: 心跳成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.HeartbeatResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: Agent 心跳上报 + tags: + - openflare-agent + /api/v1/agent/nodes/register: + post: + consumes: + - application/json + description: 使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token + parameters: + - description: 节点上报数据 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.NodePayload' + produces: + - application/json + responses: + "200": + description: 注册成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.RegistrationResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 注册或发现 Agent 节点 + tags: + - openflare-agent + /api/v1/agent/pages/deployments/{deployment_id}/hash: + get: + description: 返回 upload 框架记录的 SHA-256 哈希,供 Agent 对比本地缓存并按需拉取部署包(兼容旧路径) + parameters: + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/Wavelet_openflare_share_protocol.PagesDeploymentHashResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 查询 Pages 部署包哈希 + tags: + - openflare-agent + /api/v1/agent/pages/deployments/{deployment_id}/package: + get: + description: 流式下载指定部署的静态资源压缩包,供 Agent 边缘分发(兼容旧路径) + parameters: + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/octet-stream + responses: + "200": + description: 部署包文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 下载 Pages 部署包 + tags: + - openflare-agent + /api/v1/agent/pages/projects/{project_id}/latest/hash: + get: + description: 按项目 ID 返回当前激活部署的包哈希(类似 latest 指针),Agent 无需关心具体部署 ID + parameters: + - description: Pages 项目 ID + in: path + name: project_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/Wavelet_openflare_share_protocol.PagesProjectLatestHashResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 查询 Pages 项目最新激活部署哈希 + tags: + - openflare-agent + /api/v1/agent/pages/projects/{project_id}/latest/package: + get: + description: 按项目 ID 下载当前激活部署的压缩包,供 Agent 边缘分发 + parameters: + - description: Pages 项目 ID + in: path + name: project_id + required: true + type: integer + produces: + - application/octet-stream + responses: + "200": + description: 部署包文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 下载 Pages 项目最新激活部署包 + tags: + - openflare-agent + /api/v1/agent/waf/ip-groups/sync: + post: + consumes: + - application/json + description: 按 ID 与校验和增量同步 WAF IP 组定义 + parameters: + - description: 同步请求 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.WAFIPGroupSyncInput' + produces: + - application/json + responses: + "200": + description: 同步结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.WAFIPGroupSyncResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 同步 WAF IP 组 + tags: + - openflare-agent + /api/v1/agent/ws: + get: + description: 升级为 WebSocket 长连接,用于实时推送配置同步、WAF IP 组等指令;需携带 X-Agent-Token + responses: + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: Agent WebSocket 连接 + tags: + - openflare-agent /api/v1/cap/challenge: get: consumes: @@ -4115,7 +7309,7 @@ paths: get: consumes: - application/json - description: 返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled) + description: 返回系统配置表中 visibility 为 1 的配置键值集合 produces: - application/json responses: @@ -4126,6 +7320,4876 @@ paths: summary: 获取公共配置 tags: - config + /api/v1/d/access-logs: + get: + description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: HTTP 状态码(100-599) + in: query + name: status_code + type: integer + - description: 起始时间(RFC3339,需与 until 成对提供) + in: query + name: since + type: string + - description: 结束时间(RFC3339,需与 since 成对提供) + in: query + name: until + type: string + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/cleanup: + post: + consumes: + - application/json + description: 按保留天数清理过期访问日志记录,需要管理员权限 + parameters: + - description: 清理参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/observability.AccessLogCleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogCleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/folds: + get: + description: 按时间桶聚合访问日志并分页返回,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: 折叠时间窗口(分钟) + in: query + name: fold_minutes + type: integer + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 折叠访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.FoldedAccessLogList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出折叠访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/folds/ip-summary: + get: + description: 在指定时间桶内按 IP 聚合访问统计,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: 时间桶起始时间 + in: query + name: bucket_started_at + type: string + - description: 折叠时间窗口(分钟) + in: query + name: fold_minutes + type: integer + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 折叠 IP 汇总列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.FoldedAccessLogIPList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出折叠访问日志 IP 汇总 + tags: + - openflare-observability + /api/v1/d/access-logs/ip-summary: + get: + description: 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 统计时间范围(小时,1-720,默认 168) + in: query + name: hours + type: integer + - description: 开始时间 RFC3339(与 until 同时提供时优先于 hours) + in: query + name: since + type: string + - description: 结束时间 RFC3339 + in: query + name: until + type: string + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: IP 汇总列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPSummaryList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出访问日志 IP 汇总 + tags: + - openflare-observability + /api/v1/d/access-logs/ip-summary/analysis: + get: + description: 返回指定 IP 的汇总指标与 Top 分布,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + produces: + - application/json + responses: + "200": + description: IP 访问分析 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPAnalysisView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志 IP 分析 + tags: + - openflare-observability + /api/v1/d/access-logs/ip-summary/trend: + get: + description: 返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + - description: 时间桶粒度(分钟) + in: query + name: bucket_minutes + type: integer + produces: + - application/json + responses: + "200": + description: IP 访问趋势 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPTrendView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志 IP 趋势 + tags: + - openflare-observability + /api/v1/d/access-logs/overview: + get: + description: 返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 请求 Host(单域名) + in: query + name: host + type: string + - collectionFormat: csv + description: 请求 Host 列表(多域名精确匹配) + in: query + items: + type: string + name: hosts + type: array + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + - description: 趋势桶分钟数(1、3、5 或 60,默认 60) + in: query + name: bucket_minutes + type: integer + produces: + - application/json + responses: + "200": + description: 访问日志概览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogOverview' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志概览 + tags: + - openflare-observability + /api/v1/d/acme-accounts/default: + get: + description: 返回系统默认 ACME 账号配置,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 默认 ACME 账号 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.AcmeAccount' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取默认 ACME 账号 + tags: + - openflare-tls + /api/v1/d/apply-logs: + get: + description: 分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限 + parameters: + - description: 节点 ID 筛选 + in: query + name: node_id + type: string + - description: 页码 + in: query + name: pageNo + type: integer + - description: 页码(别名) + in: query + name: page_no + type: integer + - description: 每页数量 + in: query + name: pageSize + type: integer + - description: 每页数量(别名) + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: 下发日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/apply_log.ListResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取配置下发日志 + tags: + - openflare-apply-log + /api/v1/d/apply-logs/cleanup: + post: + consumes: + - application/json + description: 按保留天数清理历史下发记录,或删除全部记录,需要管理员权限 + parameters: + - description: 清理参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/apply_log.CleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/apply_log.CleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理配置下发日志 + tags: + - openflare-apply-log + /api/v1/d/cloudflare/connection: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.ConnectionView' + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 连接 + tags: + - openflare-cloudflare + put: + consumes: + - application/json + parameters: + - description: 连接参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.ConnectionInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.ConnectionView' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 保存 Cloudflare 连接 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/connection/clear: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清除 Cloudflare 连接 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/connection/verify: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.ConnectionView' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试 Cloudflare 连接 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/domains/available: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/cloudflare.AvailableDomain' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取可加入 Cloudflare 指向的域名 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/cloudflare.GroupItem' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向分组 + tags: + - openflare-cloudflare + post: + consumes: + - application/json + parameters: + - description: 分组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.GroupInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.GroupItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}: + get: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.GroupDetail' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向分组详情 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/delete: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members: + get: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/cloudflare.MemberItem' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向分组成员 + tags: + - openflare-cloudflare + post: + consumes: + - application/json + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.MemberCreateInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.MemberItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 添加 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员 ID + in: path + name: memberId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 移出 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员 ID + in: path + name: memberId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.SyncReceipt' + type: object + security: + - SessionCookie: [] + summary: 同步 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/{memberId}/update: + post: + consumes: + - application/json + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员 ID + in: path + name: memberId + required: true + type: integer + - description: 成员参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.MemberUpdateInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.MemberItem' + type: object + security: + - SessionCookie: [] + summary: 更新 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/sync: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.SyncReceipt' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/update: + post: + consumes: + - application/json + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 分组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.GroupInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.GroupItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/overview: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.Overview' + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向总览 + tags: + - openflare-cloudflare + /api/v1/d/config-versions: + get: + description: 返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置版本列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.ConfigVersionSummary' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取配置版本列表 + tags: + - openflare-config-version + /api/v1/d/config-versions/{id}: + get: + description: 返回指定配置版本的完整快照与渲染内容,需要管理员权限 + parameters: + - description: 配置版本 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 配置版本详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或版本不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取配置版本详情 + tags: + - openflare-config-version + /api/v1/d/config-versions/{id}/activate: + post: + description: 将指定历史版本设为当前活跃配置,需要管理员权限 + parameters: + - description: 配置版本 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 激活成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或版本不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 激活配置版本 + tags: + - openflare-config-version + /api/v1/d/config-versions/active: + get: + description: 返回当前正在使用的配置版本,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 活跃配置版本 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限、不存在或无活跃版本 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前活跃配置版本 + tags: + - openflare-config-version + /api/v1/d/config-versions/cleanup: + post: + consumes: + - application/json + description: 删除超出保留数量的非活跃配置版本,需要管理员权限 + parameters: + - description: 清理参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/config_version.CleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.CleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理历史配置版本 + tags: + - openflare-config-version + /api/v1/d/config-versions/diff: + get: + description: 对比当前草稿配置与活跃版本之间的差异,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置差异 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.ConfigDiffResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 对比草稿与活跃配置 + tags: + - openflare-config-version + /api/v1/d/config-versions/preview: + get: + description: 渲染并返回当前草稿配置的预览结果,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置预览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.ConfigPreviewResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 预览当前草稿配置 + tags: + - openflare-config-version + /api/v1/d/config-versions/publish: + post: + description: 将当前草稿配置发布为新版本,需要管理员权限 + parameters: + - description: 是否强制发布 + in: query + name: force + type: boolean + produces: + - application/json + responses: + "200": + description: 发布成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 发布配置版本 + tags: + - openflare-config-version + /api/v1/d/dashboard/overview: + get: + description: 聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 仪表盘概览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/dashboard.OverviewPayload' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取仪表盘概览 + tags: + - openflare-dashboard + /api/v1/d/dns-accounts: + get: + description: 返回全部 DNS 提供商账号,需要管理员权限 + produces: + - application/json + responses: + "200": + description: DNS 账号列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.DNSAccount' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 DNS 账号 + tags: + - openflare-tls + post: + consumes: + - application/json + description: 创建新的 DNS 提供商账号,需要管理员权限 + parameters: + - description: DNS 账号参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.DNSAccountInput' + produces: + - application/json + responses: + "200": + description: 创建成功的 DNS 账号 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.DNSAccount' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 DNS 账号 + tags: + - openflare-tls + /api/v1/d/dns-accounts/{id}/delete: + post: + description: 按 ID 删除 DNS 提供商账号,需要管理员权限 + parameters: + - description: DNS 账号 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 DNS 账号 + tags: + - openflare-tls + /api/v1/d/dns-accounts/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 DNS 提供商账号,需要管理员权限 + parameters: + - description: DNS 账号 ID + in: path + name: id + required: true + type: integer + - description: DNS 账号参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.DNSAccountInput' + produces: + - application/json + responses: + "200": + description: 更新后的 DNS 账号 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.DNSAccount' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 DNS 账号 + tags: + - openflare-tls + /api/v1/d/nodes: + get: + description: 返回所有节点及最新配置下发记录,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 节点列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/node.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取节点列表 + tags: + - openflare-node + post: + consumes: + - application/json + description: 创建新的边缘节点记录,需要管理员权限 + parameters: + - description: 节点参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/node.Input' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建节点 + tags: + - openflare-node + /api/v1/d/nodes/{id}/agent-release: + get: + description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 发布渠道 + in: query + name: channel + type: string + produces: + - application/json + responses: + "200": + description: Agent 发布信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.AgentReleaseInfo' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Agent 发布信息 + tags: + - openflare-node + /api/v1/d/nodes/{id}/agent-update: + post: + consumes: + - application/json + description: 向指定节点下发 Agent 自更新指令,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 更新参数(可选) + in: body + name: body + schema: + $ref: '#/definitions/node.AgentUpdateInput' + produces: + - application/json + responses: + "200": + description: 更新请求已下发 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 请求 Agent 更新 + tags: + - openflare-node + /api/v1/d/nodes/{id}/delete: + post: + description: 删除指定节点记录,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除节点 + tags: + - openflare-node + /api/v1/d/nodes/{id}/force-sync: + post: + description: 向指定节点下发强制同步当前活跃配置的指令,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 同步请求已下发 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 请求强制同步配置 + tags: + - openflare-node + /api/v1/d/nodes/{id}/observability: + get: + description: 返回指定节点的指标、健康事件与流量分析数据,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + - description: 返回记录数量上限 + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: 可观测性数据 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.ObservabilityView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取节点可观测性数据 + tags: + - openflare-node + /api/v1/d/nodes/{id}/observability/cleanup: + post: + description: 清理指定节点的历史健康事件记录,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.HealthEventCleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理节点健康事件 + tags: + - openflare-node + /api/v1/d/nodes/{id}/openresty-restart: + post: + description: 向指定节点下发 OpenResty 重启指令,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 重启请求已下发 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 请求重启 OpenResty + tags: + - openflare-node + /api/v1/d/nodes/{id}/update: + post: + consumes: + - application/json + description: 更新指定节点的配置信息,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 节点参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/node.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新节点 + tags: + - openflare-node + /api/v1/d/nodes/bootstrap-token: + get: + description: 返回全局节点发现引导令牌,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 引导令牌 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.BootstrapView' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取引导令牌 + tags: + - openflare-node + /api/v1/d/nodes/bootstrap-token/rotate: + post: + description: 重新生成全局节点发现引导令牌,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 新引导令牌 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.BootstrapView' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 轮换引导令牌 + tags: + - openflare-node + /api/v1/d/option: + get: + description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置项列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.OpenFlareOption' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/option/geoip/lookup: + post: + consumes: + - application/json + description: 按提供商与 IP 查询地理位置信息,需要管理员权限 + parameters: + - description: 查询参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/option.geoIPLookupRequest' + produces: + - application/json + responses: + "200": + description: GeoIP 查询结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/option.geoIPLookupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: GeoIP 地址查询 + tags: + - openflare-option + /api/v1/d/option/update: + post: + consumes: + - application/json + description: 更新单个 OpenFlare 配置项,需要管理员权限 + parameters: + - description: 配置项 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.OpenFlareOption' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/option/update-batch: + post: + consumes: + - application/json + description: 批量更新多个 OpenFlare 配置项,需要管理员权限 + parameters: + - description: 批量配置项 + in: body + name: request + required: true + schema: + $ref: '#/definitions/option.optionBatchPayload' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 批量更新 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/origins: + get: + description: 返回所有源站及关联代理规则数量,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 源站列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/origin.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取源站列表 + tags: + - openflare-origin + post: + consumes: + - application/json + description: 创建新的上游源站记录,需要管理员权限 + parameters: + - description: 源站参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/origin.Input' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建源站 + tags: + - openflare-origin + /api/v1/d/origins/{id}: + get: + description: 返回指定源站信息及关联代理规则摘要,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 源站详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.DetailView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取源站详情 + tags: + - openflare-origin + /api/v1/d/origins/{id}/delete: + post: + description: 删除指定源站记录,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除源站 + tags: + - openflare-origin + /api/v1/d/origins/{id}/update: + post: + consumes: + - application/json + description: 更新指定源站的配置信息,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + - description: 源站参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/origin.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新源站 + tags: + - openflare-origin + /api/v1/d/pages: + get: + description: 返回全部 OpenFlare Pages 项目,需要管理员权限 + produces: + - application/json + responses: + "200": + description: Pages 项目列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 项目 + tags: + - openflare-pages + post: + consumes: + - application/json + description: 创建新的 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.Input' + produces: + - application/json + responses: + "200": + description: 创建成功的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/{id}: + get: + description: 按 ID 返回 Pages 项目详情,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Pages 项目详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Pages 项目详情 + tags: + - openflare-pages + /api/v1/d/pages/{id}/delete: + post: + description: 按 ID 删除 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments: + get: + description: 返回指定项目的全部部署记录,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 部署列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.DeploymentView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 部署 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/{deployment_id}/activate: + post: + description: 将指定部署设为项目当前生效版本,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 激活后的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 激活 Pages 部署 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/{deployment_id}/delete: + post: + description: 删除指定项目的部署记录,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Pages 部署 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/upload: + post: + consumes: + - multipart/form-data + description: 为指定项目上传静态资源压缩包(zip/tar.gz/tar.xz/tar.bz2/tar/7z),需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署包文件 + in: formData + name: package + required: true + type: file + produces: + - application/json + responses: + "200": + description: 部署记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.DeploymentView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 上传 Pages 部署包 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/upload-from-url: + post: + consumes: + - application/json + deprecated: true + description: 已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 下载链接 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.UploadFromURLInput' + produces: + - application/json + responses: + "200": + description: 部署记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.DeploymentView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 从 URL 导入 Pages 部署包 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source: + get: + description: 返回项目部署源配置与运行状态,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 部署源 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署源不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/check: + post: + description: 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 任务回执 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceActionReceipt' + type: object + "400": + description: 当前来源不支持检查 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 部署源不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 来源任务正在执行 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 检查 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/delete: + post: + description: 幂等删除持久部署源;已有部署历史与当前生产部署保持不变 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 手动来源视图 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/sync: + post: + consumes: + - application/json + description: 异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 同步参数 + in: body + name: request + schema: + $ref: '#/definitions/pages.SourceSyncInput' + produces: + - application/json + responses: + "200": + description: 任务回执 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceActionReceipt' + type: object + "400": + description: 参数或来源类型无效 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 部署源不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 来源任务正在执行 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步并发布 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/update: + post: + consumes: + - application/json + description: 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署源配置 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.SourceUpdateInput' + produces: + - application/json + responses: + "200": + description: 更新结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceUpdateResult' + type: object + "400": + description: 配置无效 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 项目参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.Input' + produces: + - application/json + responses: + "200": + description: 更新后的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/deployments/{deployment_id}/files: + get: + description: 返回指定部署包含的文件清单,需要管理员权限 + parameters: + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 部署文件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.DeploymentFileView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 部署文件 + tags: + - openflare-pages + /api/v1/d/proxy-routes: + get: + description: 返回所有代理规则配置,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 代理规则列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/proxy_route.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取代理规则列表 + tags: + - openflare-proxy-route + post: + consumes: + - application/json + description: 创建新的反向代理规则,需要管理员权限 + parameters: + - description: 代理规则参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/proxy_route.Input' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建代理规则 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}: + get: + description: 返回指定代理规则的完整配置,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 代理规则详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取代理规则详情 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}/delete: + post: + description: 删除指定代理规则,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除代理规则 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}/update: + post: + consumes: + - application/json + description: 更新指定代理规则的配置,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + - description: 代理规则参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/proxy_route.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新代理规则 + tags: + - openflare-proxy-route + /api/v1/d/status: + get: + description: 返回版本、认证源与系统公开配置,无需登录 + produces: + - application/json + responses: + "200": + description: 公开状态 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/option.statusView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 获取 OpenFlare 公开状态 + tags: + - openflare-option + /api/v1/d/tls-certificates: + get: + description: 返回全部 TLS 证书(不含 PEM),需要管理员权限 + produces: + - application/json + responses: + "200": + description: 证书列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.TLSCertificate' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 TLS 证书 + tags: + - openflare-tls + post: + consumes: + - application/json + description: 从 PEM 文本创建 TLS 证书,需要管理员权限 + parameters: + - description: 证书参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.CertificateInput' + produces: + - application/json + responses: + "200": + description: 创建成功的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}: + get: + description: 按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 证书详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 TLS 证书详情 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/content: + get: + description: 按 ID 返回证书与私钥 PEM 内容,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 证书 PEM 内容 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/tls.CertificateContent' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 TLS 证书 PEM 内容 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/convert-acme: + post: + consumes: + - application/json + description: 将已上传证书转换为 ACME 自动续期模式,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 转换后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 将证书转为 ACME 管理 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/delete: + post: + description: 按 ID 删除 TLS 证书,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/renew: + post: + description: 手动触发 ACME 证书续期,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 续期后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 续期 ACME 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 TLS 证书 PEM 信息,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: 证书参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.CertificateInput' + produces: + - application/json + responses: + "200": + description: 更新后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/update-acme: + post: + consumes: + - application/json + description: 按 ID 更新 ACME 证书申请配置,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 更新后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 ACME 证书配置 + tags: + - openflare-tls + /api/v1/d/tls-certificates/apply: + post: + consumes: + - application/json + description: 通过 ACME 申请新的 TLS 证书,需要管理员权限 + parameters: + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 申请中的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 申请 ACME 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/import-file: + post: + consumes: + - multipart/form-data + description: 上传证书与私钥文件创建 TLS 证书,需要管理员权限 + parameters: + - description: 证书名称 + in: formData + name: name + type: string + - description: 备注 + in: formData + name: remark + type: string + - description: 证书文件 + in: formData + name: cert_file + required: true + type: file + - description: 私钥文件 + in: formData + name: key_file + required: true + type: file + produces: + - application/json + responses: + "200": + description: 导入成功的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 从文件导入 TLS 证书 + tags: + - openflare-tls + /api/v1/d/uptimekuma/sync: + post: + consumes: + - application/json + description: 将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 同步成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 Uptime Kuma + tags: + - openflare-option + /api/v1/d/waf/ip-groups: + get: + description: 返回全部 WAF IP 组,需要管理员权限 + produces: + - application/json + responses: + "200": + description: IP 组列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/waf.IPGroupView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 WAF IP 组 + tags: + - openflare-waf + post: + consumes: + - application/json + description: 创建新的 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupInput' + produces: + - application/json + responses: + "200": + description: 创建成功的 IP 组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}: + get: + description: 按 ID 返回 WAF IP 组详情,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: IP 组详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 WAF IP 组详情 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/delete: + post: + description: 按 ID 删除 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/sync: + post: + description: 手动触发 WAF IP 组外部 IP 同步,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 同步结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupSyncResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + - description: IP 组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupInput' + produces: + - application/json + responses: + "200": + description: 更新后的 IP 组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/test: + post: + consumes: + - application/json + description: 根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限 + parameters: + - description: 自动配置参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupAutoTestInput' + produces: + - application/json + responses: + "200": + description: 测试结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupAutoTestResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试 WAF IP 组自动配置 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups: + get: + produces: + - application/json + responses: + "200": + description: 规则列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/waf.RuleView' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 WAF 规则 + tags: + - openflare-waf + post: + consumes: + - application/json + parameters: + - description: 规则名称 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.CreateRuleInput' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 WAF 规则 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}: + get: + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 规则详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 WAF 规则详情 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/delete: + post: + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 WAF 规则 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/graph: + post: + consumes: + - application/json + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + - description: 规则图和修订号 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.SaveRuleGraphInput' + produces: + - application/json + responses: + "200": + description: 保存成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数或规则图错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 修订冲突 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 保存 WAF 规则图 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/meta: + post: + consumes: + - application/json + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + - description: 规则元数据 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.UpdateRuleMetaInput' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 WAF 规则元数据 + tags: + - openflare-waf + /api/v1/d/waf/sites/{route_id}/rule-groups: + get: + description: 返回代理站点关联的 WAF 规则组绑定,需要管理员权限 + parameters: + - description: 代理路由 ID + in: path + name: route_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 站点规则组绑定 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.SiteRuleGroupsView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取站点 WAF 规则组 + tags: + - openflare-waf + post: + consumes: + - application/json + description: 替换代理站点关联的 WAF 规则组列表,需要管理员权限 + parameters: + - description: 代理路由 ID + in: path + name: route_id + required: true + type: integer + - description: 规则组 ID 列表 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IDsRequest' + produces: + - application/json + responses: + "200": + description: 更新后的站点规则组绑定 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.SiteRuleGroupsView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 替换站点 WAF 规则组 + tags: + - openflare-waf + /api/v1/d/zones: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/zone.ListItem' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取 Zone 列表 + tags: + - openflare-zone + post: + consumes: + - application/json + parameters: + - description: Zone 参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.Input' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Zone' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Zone + tags: + - openflare-zone + /api/v1/d/zones/{id}/delete: + post: + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Zone + tags: + - openflare-zone + /api/v1/d/zones/{id}/domains: + post: + consumes: + - application/json + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 域名参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.DomainInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ZoneDomain' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Zone 域名 + tags: + - openflare-zone + /api/v1/d/zones/{id}/domains/{domainId}/delete: + post: + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 域名 ID + in: path + name: domainId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Zone 域名 + tags: + - openflare-zone + /api/v1/d/zones/{id}/domains/{domainId}/update: + post: + consumes: + - application/json + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 域名 ID + in: path + name: domainId + required: true + type: integer + - description: 域名参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.DomainInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ZoneDomain' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Zone 域名 + tags: + - openflare-zone + /api/v1/d/zones/{id}/overview: + get: + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/zone.Overview' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Zone 概览 + tags: + - openflare-zone + /api/v1/d/zones/{id}/stats: + get: + description: 按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。 + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 时间范围:24h(默认)、7d、30d + in: query + name: range + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/zone.Stats' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Zone 流量统计 + tags: + - openflare-zone + /api/v1/d/zones/{id}/update: + post: + consumes: + - application/json + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: Zone 参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.Input' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Zone' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Zone + tags: + - openflare-zone /api/v1/message-gateway/bindings: get: description: Returns the current user's bound messaging channels @@ -4476,6 +12540,195 @@ paths: summary: 获取当前登录用户信息 tags: - oauth + /api/v1/relay/heartbeat: + post: + consumes: + - application/json + description: Relay 节点定期上报运行状态与 frps 观测数据,返回运行时配置 + parameters: + - description: 心跳载荷 + in: body + name: body + required: true + schema: + $ref: '#/definitions/relay.HeartbeatPayload' + produces: + - application/json + responses: + "200": + description: 心跳响应 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/relay.HeartbeatResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Agent Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 上报 Relay 心跳 + tags: + - openflare-relay + /api/v1/relay/ws: + get: + description: 将已认证的 Relay 连接升级为 WebSocket 长连接,用于配置推送 + responses: + "401": + description: Agent Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 升级 Relay WebSocket 连接 + tags: + - openflare-relay + /api/v1/tunnel/apply-log: + post: + consumes: + - application/json + description: Tunnel 客户端上报配置应用结果,服务端记录下发日志 + parameters: + - description: 下发结果载荷 + in: body + name: body + required: true + schema: + $ref: '#/definitions/flared.ApplyLogPayload' + produces: + - application/json + responses: + "200": + description: 下发日志记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.OpenFlareApplyLog' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 上报 Tunnel 配置下发结果 + tags: + - openflare-tunnel + /api/v1/tunnel/config/active: + get: + description: 返回 Tunnel 客户端当前应应用的完整路由配置(含中继列表与代理定义) + produces: + - application/json + responses: + "200": + description: 隧道配置 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/flared.TunnelConfigResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 获取活跃隧道配置 + tags: + - openflare-tunnel + /api/v1/tunnel/heartbeat: + post: + consumes: + - application/json + description: Tunnel 客户端定期上报运行状态与中继连接信息,返回活跃配置元数据与隧道设置 + parameters: + - description: 心跳载荷 + in: body + name: body + required: true + schema: + $ref: '#/definitions/flared.HeartbeatPayload' + produces: + - application/json + responses: + "200": + description: 心跳响应 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/flared.HeartbeatResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 上报 Tunnel 心跳 + tags: + - openflare-tunnel + /api/v1/tunnel/ws: + get: + description: 将已认证的 Tunnel 客户端连接升级为 WebSocket 长连接,用于配置推送 + responses: + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 升级 Tunnel WebSocket 连接 + tags: + - openflare-tunnel /api/v1/upload: post: consumes: @@ -4944,13 +13197,6 @@ paths: consumes: - application/json description: 向指定邮箱发送验证码(用于注册场景) - parameters: - - description: 目标邮箱 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.sendEmailCodeRequest' produces: - application/json responses: @@ -4962,10 +13208,6 @@ paths: description: 参数错误 schema: $ref: '#/definitions/response.Any' - "500": - description: 发送失败 - schema: - $ref: '#/definitions/response.Any' summary: 发送邮箱验证码 tags: - user diff --git a/backend/go.mod b/backend/go.mod index 8a2303a5..d4d04a12 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,12 +10,16 @@ require ( 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/service/s3 v1.106.5 + github.com/bodgit/sevenzip v1.6.5 github.com/bwmarrin/snowflake v0.3.0 github.com/coreos/go-oidc/v3 v3.20.0 github.com/deepteams/webp v1.2.7 + github.com/dgraph-io/ristretto/v2 v2.4.2 + github.com/expr-lang/expr v1.17.8 github.com/gin-contrib/sessions v1.1.0 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/google/uuid v1.6.0 @@ -23,6 +27,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hibiken/asynq v0.26.0 github.com/maypok86/otter/v2 v2.3.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 github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 @@ -37,7 +42,9 @@ require ( github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 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/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 go.opentelemetry.io/otel v1.45.0 @@ -48,6 +55,7 @@ require ( golang.org/x/crypto v0.54.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/oauth2 v0.36.0 golang.org/x/sync v0.22.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -57,7 +65,7 @@ require ( 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.14 + gorm.io/plugin/opentelemetry v0.1.16 ) require ( @@ -79,48 +87,51 @@ require ( 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/boj/redistore v1.4.1 // 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/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.1 // 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.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // 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.21.2 // indirect + github.com/glebarez/go-sqlite v1.23.0 // indirect github.com/go-faster/city v1.0.1 // indirect - github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/errors v0.8.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 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-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-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.6.0 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // 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.0.0 // indirect + github.com/google/btree v1.1.3 // 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.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.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 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -128,19 +139,20 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect + github.com/klauspost/compress v1.19.2 // 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 - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 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.27 // indirect + github.com/pierrec/lz4/v4 v4.1.28 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.61.0 // indirect @@ -152,37 +164,39 @@ require ( 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.1 // indirect + github.com/ugorji/go/codec v1.3.2 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect - github.com/yuin/gopher-lua v1.1.1 // 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.12.2 // indirect + go.opentelemetry.io/otel/log v0.6.0 // 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 go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.org/x/arch v0.29.0 // indirect - golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.47.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 gorm.io/driver/mysql v1.6.0 // indirect - modernc.org/libc v1.74.3 // indirect + modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.54.0 // indirect + modernc.org/sqlite v1.56.0 // indirect ) + +exclude github.com/gomodule/redigo v2.0.0+incompatible diff --git a/backend/go.sum b/backend/go.sum index 4b9b4830..574eb7ec 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -124,8 +124,14 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 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/boj/redistore v1.4.1 h1:lP9ZZWqKMq2RIqexlZX1w1ODSnegL+puxGIujkU5tIw= -github.com/boj/redistore v1.4.1/go.mod h1:c0Tvw6aMjslog4jHIAcNv6EtJM849YoOAhMY7JBbWpI= +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/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= @@ -171,10 +177,15 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deepteams/webp v1.2.7 h1:Oj3iXbZ0U4siY1KHFTF5T21uysZkmor8pT4E38mNK2o= github.com/deepteams/webp v1.2.7/go.mod h1:J8Ap+HAixxpKKRN9IpEeSKlfvhsef1v43jKTO7m3f4c= +github.com/dgraph-io/ristretto/v2 v2.4.2 h1:x0cvjmUKxt764Yxdk2nr94we1AvPPAMh1rh5TQ+Jo80= +github.com/dgraph-io/ristretto/v2 v2.4.2/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -189,6 +200,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= @@ -201,8 +214,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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= 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= @@ -214,14 +227,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.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +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/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.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= -github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +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-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= @@ -238,29 +253,33 @@ 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 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-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-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= @@ -274,14 +293,15 @@ 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-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4= 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= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= 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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +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= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= @@ -327,8 +347,9 @@ 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= @@ -368,8 +389,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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/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= @@ -394,8 +415,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +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/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= @@ -419,7 +440,6 @@ github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaX github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= @@ -453,16 +473,17 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 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.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +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/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= @@ -506,6 +527,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= @@ -518,8 +541,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 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= @@ -532,6 +556,8 @@ github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vv github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= @@ -542,8 +568,8 @@ 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.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= -github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/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= @@ -628,11 +654,15 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8= +github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -669,8 +699,10 @@ github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso 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.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= -github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +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/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= @@ -683,8 +715,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= @@ -716,8 +748,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.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc= -go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E= +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/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= @@ -742,8 +774,11 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= +go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1008,8 +1043,8 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= 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= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -1068,8 +1103,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1293,8 +1328,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.14 h1:xivP39t/0JgcceDl+BLwVAJHihjFEUj0ZocMSBwZ7ZY= -gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g= +gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= +gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= 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= @@ -1314,8 +1349,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.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= -modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= 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= @@ -1324,8 +1359,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.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= -modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= 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= diff --git a/backend/main.go b/backend/main.go index 6df04fd6..5a4076db 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,16 +1,16 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package main 是 Wavelet 平台的程序入口 +// Package main 是 OpenFlare 平台的程序入口 package main import "Wavelet/cmd" -// @title Wavelet API +// @title OpenFlare API // @version 1.0.0 -// @description Wavelet 平台后端 API,提供用户认证、系统配置、任务调度等通用功能。 -// @contact.name Wavelet -// @contact.url https://github.com/Rain-kl/Wavelet +// @description OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。 +// @contact.name OpenFlare +// @contact.url https://github.com/Rain-kl/OpenFlare // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html // @BasePath / diff --git a/backend/openflare/README.md b/backend/openflare/README.md new file mode 100644 index 00000000..0b54934d --- /dev/null +++ b/backend/openflare/README.md @@ -0,0 +1,52 @@ +# OpenFlare 下游树 + +本目录占据上游 `backend/downstream/` 的位置,存放 OpenFlare 的全部业务, +按功能职责拆为 **4 个插件 + 1 个共享层**。上游目录 +(`backend/{core,pkg,plugins}`)通过 `git fetch wavelet && git merge wavelet/main` +吸收(第一次接线已 merge `wavelet/feat/cordis-alignment`,待该分支合入上游 main +后改走 `wavelet/main`)。本目录、`frontend/` 与 `backend/cmd` 由本仓库持有。 +合并前请 `git config include.path ../.gitconfig`(或 worktree 安全写法 +`git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"`), +以启用 `.gitattributes` 的 `merge=ours`。 + +``` +backend/openflare/ +├── plugins/ +│ ├── server/ # 控制面插件:站点/区域/Cloudflare/Pages/WAF/节点/回源/健康/配置版本 +│ │ ├── openflare/ admin/ oauth/ user/ upload/ cap/ config/ health/ # 业务域 +│ │ ├── repository/ model/ infra/ shared/ pkg/ # 支撑层 +│ │ └── router/ platform/ listener/ integration/ testhelper/ # 装配与接线 +│ ├── agent/ # 边缘 nginx/WAF 代理守护进程插件 +│ ├── relay/ # frps 中继守护进程插件 +│ └── flared/ # frpc 隧道客户端守护进程插件 +└── share/ # 插件间共享资源(见 share/README.md) +``` + +装配根在 `backend/cmd`(与上游同构):`main.go` + `cmd/*.go` 为 server 的 +api/worker/schedule/all profile 入口,`cmd/{agent,relay,flared}/main.go` 为三个 +守护进程入口。 + +## 依赖规则 + +1. `plugins/` 与 `plugins/` 之间禁止互相 import;需要协作时走 `core/contracts` + 或 `ctx.Events()`。 +2. 插件只允许 import 本插件内部包、`Wavelet/core`、`Wavelet/core/contracts`、 + `Wavelet/pkg` 与 `Wavelet/openflare/share`。 +3. `share/` 禁止 import 任何插件实现与下游业务包。 +4. 表单一所有者:`of_*` 全部由 `server` 插件建表与读写;`w_*` 由上游平台插件拥有, + 下游只能经契约或事件访问。 + +## 收敛路线 + +- 已完成:`agent`/`relay`/`flared` 各有 `plugin.go` 实现 `core.Plugin` + `core.Driver` + (`DriverTypeAgent`/`DriverTypeRelay`/`DriverTypeFlared`),入口 `backend/cmd/{agent,relay,flared}/main.go` + 已改为 `core.NewApp(core.WithProfile(...))` + `app.Run()` 装配,`-config` 旗标、 + 默认路径、退出码与启动日志保持原样;JSON 配置由各插件 `Apply` 自行加载。 +- 已完成:`server/plugin.go` 实现 `core.Plugin`,`Apply` 以 `ctx.Router().Group(api_prefix)` + 声明根级与 `/v1` 全部路由;`router.Serve` 已删除,装配根改为 + `core.App` + `driver_http.New(WithEngine(router.BuildEngine()))`,监听与优雅退出归内核。 + 路由保真由 `plugin_parity_test.go` 对拍 `baseline/routes-engine.txt`(256 条方法+路径)保证。 +- 待办:`platform/bootstrap` 的任务、设置与迁移注册迁入 `Apply`;各业务域按插件标准 + 分层规范收敛到 `handler/ service/ repository/ model/ errs/ migrations/`(模式 2); + 引擎级中间件、NoRoute 前端兜底与白名单生效三项能力回流上游后,去掉 + `BuildEngine` 交给 `WithEngine` 这一例外。 diff --git a/backend/openflare/plugins/agent/agent/runner.go b/backend/openflare/plugins/agent/agent/runner.go new file mode 100644 index 00000000..d308817d --- /dev/null +++ b/backend/openflare/plugins/agent/agent/runner.go @@ -0,0 +1,535 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package agent implements the local OpenFlare agent runtime loop. +package agent + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + "time" + + "Wavelet/openflare/plugins/agent/config" + agentheartbeat "Wavelet/openflare/plugins/agent/heartbeat" + "Wavelet/openflare/plugins/agent/protocol" + "Wavelet/openflare/plugins/agent/state" + "Wavelet/openflare/plugins/agent/wsclient" + edgeheartbeat "Wavelet/openflare/share/edge/heartbeat" + "Wavelet/pkg/util" +) + +// HeartbeatService handles node registration and periodic heartbeat reporting. +type HeartbeatService interface { + Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) + Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) + SetToken(token string) +} + +// SyncService handles configuration synchronisation between the agent and the server. +type SyncService interface { + SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error + SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error + ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error + WAFIPGroupChecksums() (map[string]string, error) + ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error +} + +// RuntimeManager manages the lifecycle and health checks of the OpenResty runtime. +type RuntimeManager interface { + CheckHealth(ctx context.Context) error + Restart(ctx context.Context) error +} + +// WebSocketService manages the persistent WebSocket connection to the server. +type WebSocketService interface { + Connect(ctx context.Context) (protocol.WebSocketConnection, error) + SetToken(token string) + URL() string +} + +const websocketBackoffDefaultDelay = 30 * time.Second + +// Runner coordinates the agent's heartbeat, configuration sync, and WebSocket upgrade lifecycle. +type Runner struct { + Config *config.Config + StateStore *state.Store + HeartbeatCycle *agentheartbeat.Cycle + HeartbeatService HeartbeatService + SyncService SyncService + RuntimeManager RuntimeManager + WebSocketService WebSocketService + + restartOpenrestyNow bool + websocketUpgradeEnabled bool +} + +// Run starts the agent's main loop, performing heartbeats and upgrading to WebSocket when available. +func (r *Runner) Run(ctx context.Context) error { + if r.HeartbeatCycle != nil { + r.HeartbeatCycle.RecordSyncError = r.recordSyncError + } + nodeID, err := r.StateStore.EnsureNodeID() + if err != nil { + return err + } + slog.Info("agent runner started", "node_id", nodeID, "node", r.Config.NodeName, "ip", r.Config.NodeIP) + r.runStartupAuth(ctx, &nodeID) + + heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration()) + defer heartbeatTicker.Stop() + var wsDone <-chan error + wsBackoff := newWebSocketBackoff() + nextWSAttempt := time.Now() + tryStartWebSocket := func() { + if wsDone != nil || !r.shouldUseWebSocket() || time.Now().Before(nextWSAttempt) { + return + } + done, startErr := r.startWebSocket(ctx, nodeID) + if startErr != nil { + delay := wsBackoff.Next() + nextWSAttempt = time.Now().Add(delay) + slog.Debug("agent ws upgrade failed; falling back to http heartbeat", + "enabled", r.websocketUpgradeEnabled, + "url", r.websocketURL(), + "retry_after", delay, + "error", startErr, + ) + return + } + wsBackoff.Reset() + wsDone = done + slog.Debug("agent switched to websocket mode", "url", r.websocketURL()) + } + tryStartWebSocket() + + for { + select { + case <-ctx.Done(): + slog.Info("agent runner shutting down", "error", ctx.Err()) + return ctx.Err() + case wsErr := <-wsDone: + wsDone = nil + delay := wsBackoff.Next() + nextWSAttempt = time.Now().Add(delay) + slog.Debug("agent ws disconnected; resuming http heartbeat", "retry_after", delay, "error", wsErr) + r.handleWSDisconnect(ctx, nodeID) + case <-heartbeatTicker.C: + if wsDone != nil { + continue + } + r.handleHeartbeatTick(ctx, &nodeID, heartbeatTicker, tryStartWebSocket) + } + } +} + +func (r *Runner) runStartupAuth(ctx context.Context, nodeID *string) { + if r.hasAccessToken() { + if _, hbErr := r.performHeartbeatCycle(ctx, *nodeID, true); hbErr != nil { + slog.Error("agent startup heartbeat failed", "error", hbErr) + } + return + } + if err := r.tryRegister(ctx, nodeID); err != nil { + slog.Error("agent initial discovery register failed", "error", err) + } +} + +func (r *Runner) handleWSDisconnect(ctx context.Context, nodeID string) { + if !r.hasAccessToken() { + return + } + if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil { + slog.Error("agent heartbeat after ws disconnect failed", "error", hbErr) + } +} + +func (r *Runner) handleHeartbeatTick(ctx context.Context, nodeID *string, heartbeatTicker *time.Ticker, tryStartWebSocket func()) { + if !r.hasAccessToken() { + if err := r.tryRegister(ctx, nodeID); err != nil { + slog.Error("agent discovery register failed", "error", err) + } + return + } + changed, hbErr := r.performHeartbeatCycle(ctx, *nodeID, false) + if hbErr != nil { + slog.Error("agent heartbeat failed", "error", hbErr) + return + } + if changed { + heartbeatTicker.Reset(r.Config.HeartbeatInterval.Duration()) + } + tryStartWebSocket() +} + +func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, startup bool) (bool, error) { + r.refreshOpenrestyHealth(ctx) + return r.HeartbeatCycle.Perform(ctx, nodeID, startup, r) +} + +// Apply applies the provided agent settings and reports whether the heartbeat interval changed. +func (r *Runner) Apply(settings *protocol.AgentSettings) bool { + return r.applySettings(settings) +} + +// RestartOpenrestyIfNeeded restarts OpenResty when a server-requested restart is pending. +func (r *Runner) RestartOpenrestyIfNeeded(ctx context.Context) { + r.tryRestartOpenresty(ctx) +} + +func (r *Runner) shouldUseWebSocket() bool { + enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAccessToken() + slog.Debug("agent ws upgrade eligibility checked", "enabled", enabled, "server_enabled", r.websocketUpgradeEnabled, "url", r.websocketURL()) + return enabled +} + +func (r *Runner) websocketURL() string { + if r.WebSocketService == nil { + return "" + } + return r.WebSocketService.URL() +} + +func (r *Runner) startWebSocket(ctx context.Context, nodeID string) (<-chan error, error) { + if r.WebSocketService == nil { + return nil, errors.New("websocket service is not configured") + } + conn, err := r.WebSocketService.Connect(ctx) + if err != nil { + return nil, err + } + done := make(chan error, 1) + util.Go(func() { + defer func() { + _ = conn.Close() + }() + done <- r.runWebSocket(ctx, nodeID, conn) + }) + return done, nil +} + +type agentWSHandler struct { + runner *Runner + conn protocol.WebSocketConnection + nodeID string + statusTicker *time.Ticker +} + +func (h *agentWSHandler) OnConnect(ctx context.Context) error { + return h.runner.sendWebSocketStatus(ctx, h.nodeID, h.conn) +} + +func (h *agentWSHandler) HandleMessage(ctx context.Context, msg wsclient.WSMessage) error { + var payloadBytes []byte + if msg.Payload != nil { + payloadBytes = []byte(msg.Payload) + } + protoMsg := protocol.WSMessage{ + Type: msg.Type, + Payload: payloadBytes, + } + changed, err := h.runner.handleWebSocketMessage(ctx, protoMsg, h.conn) + if err != nil { + return err + } + if changed { + h.statusTicker.Reset(h.runner.Config.HeartbeatInterval.Duration()) + } + return nil +} + +func (h *agentWSHandler) OnClose(err error) { + slog.Error("agent ws receive failed", "error", err) +} + +func (r *Runner) runWebSocket(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error { + slog.Debug("agent ws connected", "url", conn.URL(), "node_id", nodeID) + statusTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration()) + defer statusTicker.Stop() + + childCtx, cancel := context.WithCancel(ctx) + defer cancel() + + util.Go(func() { + for { + select { + case <-childCtx.Done(): + return + case <-statusTicker.C: + if err := r.sendWebSocketStatus(childCtx, nodeID, conn); err != nil { + slog.Error("agent ws send status failed", "error", err) + _ = conn.Close() + return + } + } + } + }) + + wsConn, ok := conn.(*wsclient.Connection) + if !ok { + return errors.New("invalid websocket connection type") + } + + return wsConn.RunReceiveLoop(childCtx, &agentWSHandler{ + runner: r, + conn: conn, + nodeID: nodeID, + statusTicker: statusTicker, + }) +} + +func (r *Runner) sendWebSocketStatus(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error { + r.refreshOpenrestyHealth(ctx) + payload, ackWindows := r.HeartbeatCycle.PrepareHeartbeatPayload(ctx, nodeID) + if err := conn.SendStatus(payload); err != nil { + return err + } + r.HeartbeatCycle.AckObservabilityWindows(ackWindows) + return nil +} + +func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WSMessage, conn protocol.WebSocketConnection) (bool, error) { + switch message.Type { + case protocol.WSMessageTypeSettings: + var settings protocol.AgentSettings + if err := json.Unmarshal(message.Payload, &settings); err != nil { + slog.Debug("agent ws settings decode failed", "error", err) + return false, nil + } + changed := r.applySettings(&settings) + r.tryRestartOpenresty(ctx) + edgeheartbeat.TryAutoUpdate(ctx, r.HeartbeatCycle.Updater, agentheartbeat.AgentSettingsToAutoUpdate(&settings), "agent") + if !r.websocketUpgradeEnabled { + slog.Debug("agent ws disabled by server settings; falling back to http heartbeat") + return changed, errors.New("websocket upgrade disabled by server") + } + return changed, nil + case protocol.WSMessageTypeActiveConfig: + var target protocol.ActiveConfigMeta + if err := json.Unmarshal(message.Payload, &target); err != nil { + slog.Debug("agent ws active config decode failed", "error", err) + return false, nil + } + slog.Debug("agent ws active config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true) + if err := r.SyncService.SyncOnce(ctx, &target); err != nil { + r.recordSyncError(err) + slog.Error("agent ws triggered sync failed", "version", target.Version, "error", err) + } + return false, nil + case protocol.WSMessageTypeForceSyncConfig: + var target protocol.ActiveConfigMeta + if err := json.Unmarshal(message.Payload, &target); err != nil { + slog.Debug("agent ws force sync config decode failed", "error", err) + return false, nil + } + slog.Debug("agent ws force sync config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true) + if err := r.SyncService.ForceSyncOnce(ctx, &target); err != nil { + r.recordSyncError(err) + slog.Error("agent ws triggered force sync failed", "version", target.Version, "error", err) + } + return false, nil + case protocol.WSMessageTypeWAFIPGroups: + var groups []protocol.WAFIPGroup + if err := json.Unmarshal(message.Payload, &groups); err != nil { + slog.Debug("agent ws waf ip groups decode failed", "error", err) + return false, nil + } + r.HeartbeatCycle.ApplyWAFIPGroups(ctx, groups) + return false, nil + case protocol.WSMessageTypePing: + slog.Debug("agent ws ping received") + return false, conn.SendPong() + case protocol.WSMessageTypePong: + slog.Debug("agent ws pong received") + return false, nil + default: + slog.Debug("agent ws unsupported message type", "type", message.Type) + return false, nil + } +} + +type webSocketBackoff struct { + delays []time.Duration + index int +} + +func newWebSocketBackoff() *webSocketBackoff { + return &webSocketBackoff{ + delays: []time.Duration{ + time.Second, + 2 * time.Second, + 5 * time.Second, + 10 * time.Second, + 30 * time.Second, + }, + } +} + +func (backoff *webSocketBackoff) Next() time.Duration { + if backoff == nil || len(backoff.delays) == 0 { + return websocketBackoffDefaultDelay + } + if backoff.index >= len(backoff.delays) { + return backoff.delays[len(backoff.delays)-1] + } + delay := backoff.delays[backoff.index] + backoff.index++ + return delay +} + +func (backoff *webSocketBackoff) Reset() { + if backoff != nil { + backoff.index = 0 + } +} + +func (r *Runner) hasAccessToken() bool { + return strings.TrimSpace(r.Config.AccessToken) != "" +} + +func (r *Runner) applySettings(settings *protocol.AgentSettings) bool { + if settings == nil { + return false + } + changed := false + if settings.HeartbeatInterval > 0 { + newInterval := config.MillisecondDuration(time.Duration(settings.HeartbeatInterval) * time.Millisecond) + if newInterval != r.Config.HeartbeatInterval { + slog.Info("agent heartbeat interval updated", "from", r.Config.HeartbeatInterval, "to", newInterval) + r.Config.HeartbeatInterval = newInterval + changed = true + } + } + if settings.WebsocketUpgradeEnabled != r.websocketUpgradeEnabled { + slog.Debug("agent websocket upgrade setting updated", "from", r.websocketUpgradeEnabled, "to", settings.WebsocketUpgradeEnabled) + } + r.websocketUpgradeEnabled = settings.WebsocketUpgradeEnabled + r.restartOpenrestyNow = settings.RestartOpenrestyNow + return changed +} + +func (r *Runner) tryRestartOpenresty(ctx context.Context) { + if !r.restartOpenrestyNow { + return + } + r.restartOpenrestyNow = false + if r.RuntimeManager == nil { + return + } + slog.Info("agent openresty restart requested by server") + if err := r.RuntimeManager.Restart(ctx); err != nil { + slog.Error("agent openresty restart failed", "error", err) + r.recordOpenrestyUnhealthy(err, false) + return + } + slog.Info("agent openresty restart succeeded") + r.recordOpenrestyHealthy() +} + +func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error { + if strings.TrimSpace(r.Config.DiscoveryToken) == "" { + return errors.New("agent_token 为空且未配置 discovery_token") + } + slog.Info("agent discovery registration started") + response, err := r.HeartbeatService.Register(ctx, r.HeartbeatCycle.NodePayload(ctx, *nodeID)) + if err != nil { + return err + } + if response == nil || strings.TrimSpace(response.AccessToken) == "" || strings.TrimSpace(response.NodeID) == "" { + return errors.New("discovery register response 缺少 node_id 或 agent_token") + } + snapshot, err := r.StateStore.Load() + if err != nil { + return err + } + snapshot.NodeID = response.NodeID + if err = r.StateStore.Save(snapshot); err != nil { + return err + } + r.Config.AccessToken = response.AccessToken + r.Config.DiscoveryToken = "" + if err = r.Config.Save(); err != nil { + return err + } + r.HeartbeatService.SetToken(response.AccessToken) + if r.WebSocketService != nil { + r.WebSocketService.SetToken(response.AccessToken) + } + *nodeID = response.NodeID + slog.Info("agent discovery registration succeeded", "node_id", response.NodeID) + r.refreshOpenrestyHealth(ctx) + if _, err = r.HeartbeatCycle.Perform(ctx, *nodeID, true, r); err != nil { + slog.Error("agent post-register heartbeat failed", "error", err) + } + return nil +} + +func (r *Runner) recordSyncError(err error) { + if err == nil || r.StateStore == nil { + return + } + snapshot, loadErr := r.StateStore.Load() + if loadErr != nil { + slog.Error("load state before recording sync error failed", "error", loadErr) + return + } + snapshot.LastError = err.Error() + slog.Warn("recording sync error into state", "error", snapshot.LastError) + if saveErr := r.StateStore.Save(snapshot); saveErr != nil { + slog.Error("save state after sync error failed", "error", saveErr) + } +} + +func (r *Runner) refreshOpenrestyHealth(ctx context.Context) { + if r.RuntimeManager == nil || r.StateStore == nil { + return + } + if err := r.RuntimeManager.CheckHealth(ctx); err != nil { + if strings.Contains(err.Error(), "openresty config not exists") { + return + } + r.recordOpenrestyUnhealthy(err, true) + return + } + r.recordOpenrestyHealthy() +} + +func (r *Runner) recordOpenrestyHealthy() { + if r.StateStore == nil { + return + } + snapshot, err := r.StateStore.Load() + if err != nil { + slog.Error("load state before recording openresty health failed", "error", err) + return + } + if snapshot.OpenrestyStatus == protocol.OpenrestyStatusHealthy && strings.TrimSpace(snapshot.OpenrestyMessage) == "" { + return + } + snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy + snapshot.OpenrestyMessage = "" + if err = r.StateStore.Save(snapshot); err != nil { + slog.Error("save state after recording openresty health failed", "error", err) + } +} + +func (r *Runner) recordOpenrestyUnhealthy(err error, fallbackOnly bool) { + if err == nil || r.StateStore == nil { + return + } + snapshot, loadErr := r.StateStore.Load() + if loadErr != nil { + slog.Error("load state before recording openresty error failed", "error", loadErr) + return + } + message := strings.TrimSpace(err.Error()) + if !fallbackOnly || strings.TrimSpace(snapshot.OpenrestyMessage) == "" { + snapshot.OpenrestyMessage = message + } + snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy + if saveErr := r.StateStore.Save(snapshot); saveErr != nil { + slog.Error("save state after recording openresty error failed", "error", saveErr) + } +} diff --git a/backend/openflare/plugins/agent/agent/runner_test.go b/backend/openflare/plugins/agent/agent/runner_test.go new file mode 100644 index 00000000..391a721f --- /dev/null +++ b/backend/openflare/plugins/agent/agent/runner_test.go @@ -0,0 +1,662 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "Wavelet/openflare/plugins/agent/config" + agentheartbeat "Wavelet/openflare/plugins/agent/heartbeat" + "Wavelet/openflare/plugins/agent/protocol" + "Wavelet/openflare/plugins/agent/state" + "Wavelet/openflare/plugins/agent/updater" +) + +func withHeartbeatCycle(runner *Runner, observabilityBuffer *state.ObservabilityBufferStore) *Runner { + runner.HeartbeatCycle = &agentheartbeat.Cycle{ + Config: runner.Config, + StateStore: runner.StateStore, + ObservabilityBuffer: observabilityBuffer, + Heartbeat: runner.HeartbeatService, + Sync: runner.SyncService, + Updater: updater.New(), + } + return runner +} + +type fakeHeartbeatService struct { + mu sync.Mutex + registerCalls int + heartbeatCalls int + registerErr error + registerResp *protocol.RegisterNodeResponse + heartbeatErrs []error + heartbeatResults []*protocol.HeartbeatResult + heartbeatPayloads []protocol.NodePayload + onHeartbeat func(int) + lastToken string +} + +func (f *fakeHeartbeatService) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.registerCalls++ + return f.registerResp, f.registerErr +} + +func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) { + f.mu.Lock() + f.heartbeatCalls++ + callIndex := f.heartbeatCalls + f.heartbeatPayloads = append(f.heartbeatPayloads, payload) + var err error + if len(f.heartbeatErrs) >= callIndex { + err = f.heartbeatErrs[callIndex-1] + } + var result *protocol.HeartbeatResult + if len(f.heartbeatResults) >= callIndex { + result = f.heartbeatResults[callIndex-1] + } + onHeartbeat := f.onHeartbeat + f.mu.Unlock() + if onHeartbeat != nil { + onHeartbeat(callIndex) + } + return result, err +} + +func (f *fakeHeartbeatService) SetToken(token string) { + f.mu.Lock() + defer f.mu.Unlock() + f.lastToken = token +} + +type fakeSyncService struct { + mu sync.Mutex + startupErr error + syncOnceErr error + startupCalls int + syncOnceCalls int + lastTarget *protocol.ActiveConfigMeta + onSyncOnceCall func(int) + wafChecksums map[string]string + wafGroups []protocol.WAFIPGroup +} + +type fakeRuntimeManager struct { + mu sync.Mutex + healthErr error + restartErr error + restartCalls int + clearHealthOnRestart bool +} + +func (f *fakeRuntimeManager) CheckHealth(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + return f.healthErr +} + +func (f *fakeRuntimeManager) Restart(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + f.restartCalls++ + if f.clearHealthOnRestart && f.restartErr == nil { + f.healthErr = nil + } + return f.restartErr +} + +func (f *fakeSyncService) SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error { + f.mu.Lock() + defer f.mu.Unlock() + f.startupCalls++ + return f.startupErr +} + +func (f *fakeSyncService) SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error { + f.mu.Lock() + f.syncOnceCalls++ + if target != nil { + copied := *target + f.lastTarget = &copied + } + callIndex := f.syncOnceCalls + callback := f.onSyncOnceCall + f.mu.Unlock() + if callback != nil { + callback(callIndex) + } + return f.syncOnceErr +} + +func (f *fakeSyncService) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error { + f.mu.Lock() + f.syncOnceCalls++ + if target != nil { + copied := *target + f.lastTarget = &copied + } + callIndex := f.syncOnceCalls + callback := f.onSyncOnceCall + f.mu.Unlock() + if callback != nil { + callback(callIndex) + } + return f.syncOnceErr +} + +func (f *fakeSyncService) WAFIPGroupChecksums() (map[string]string, error) { + if f.wafChecksums == nil { + return map[string]string{}, nil + } + return f.wafChecksums, nil +} + +func (f *fakeSyncService) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error { + f.mu.Lock() + defer f.mu.Unlock() + f.wafGroups = append(f.wafGroups, groups...) + return nil +} + +type fakeWebSocketConnection struct { + pongCalls int +} + +func (f *fakeWebSocketConnection) URL() string { + return "ws://127.0.0.1/api/v1/agent/ws" +} + +func (f *fakeWebSocketConnection) SendStatus(payload protocol.NodePayload) error { + return nil +} + +func (f *fakeWebSocketConnection) SendPong() error { + f.pongCalls++ + return nil +} + +func (f *fakeWebSocketConnection) Receive() (protocol.WSMessage, error) { + return protocol.WSMessage{}, errors.New("not implemented") +} + +func (f *fakeWebSocketConnection) Close() error { + return nil +} + +func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + heartbeatService := &fakeHeartbeatService{ + heartbeatResults: []*protocol.HeartbeatResult{{}}, + onHeartbeat: func(callCount int) { + if callCount >= 2 { + cancel() + } + }, + } + syncService := &fakeSyncService{ + startupErr: errors.New("当前没有激活版本,保持当前 OpenResty 配置"), + } + runner := withHeartbeatCycle(&Runner{ + Config: &config.Config{ + AccessToken: "agent-token", + NodeName: "edge-01", + NodeIP: "10.0.0.8", + NodeIPConfigured: true, + Version: config.Version, + ExtVersion: "1.27.1.2", + HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), + }, + StateStore: stateStore, + HeartbeatService: heartbeatService, + SyncService: syncService, + }, nil) + + err := runner.Run(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + if heartbeatService.registerCalls != 0 { + t.Fatalf("expected no discovery register call, got %d", heartbeatService.registerCalls) + } + if heartbeatService.heartbeatCalls < 2 { + t.Fatalf("expected heartbeat loop to continue, got %d heartbeat calls", heartbeatService.heartbeatCalls) + } + snapshot, loadErr := stateStore.Load() + if loadErr != nil { + t.Fatalf("failed to load state: %v", loadErr) + } + if snapshot.LastError != "当前没有激活版本,保持当前 OpenResty 配置" { + t.Fatalf("expected startup sync error to be recorded, got %q", snapshot.LastError) + } +} + +func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + heartbeatService := &fakeHeartbeatService{ + registerErr: errors.New("register timeout"), + heartbeatErrs: []error{errors.New("heartbeat timeout")}, + heartbeatResults: []*protocol.HeartbeatResult{ + {}, + }, + } + syncService := &fakeSyncService{ + syncOnceErr: errors.New("openresty reload failed"), + onSyncOnceCall: func(callCount int) { + if callCount >= 1 { + cancel() + } + }, + } + runner := withHeartbeatCycle(&Runner{ + Config: &config.Config{ + AccessToken: "agent-token", + NodeName: "edge-01", + NodeIP: "10.0.0.8", + NodeIPConfigured: true, + Version: config.Version, + ExtVersion: "1.27.1.2", + HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), + }, + StateStore: stateStore, + HeartbeatService: heartbeatService, + SyncService: syncService, + }, nil) + + err := runner.Run(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + if heartbeatService.registerCalls != 0 { + t.Fatalf("expected no register attempt, got %d", heartbeatService.registerCalls) + } + if syncService.syncOnceCalls == 0 { + t.Fatal("expected sync loop to continue after heartbeat/register errors") + } + snapshot, loadErr := stateStore.Load() + if loadErr != nil { + t.Fatalf("failed to load state: %v", loadErr) + } + if snapshot.LastError != "openresty reload failed" { + t.Fatalf("expected sync error to be recorded, got %q", snapshot.LastError) + } +} + +func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + if err := stateStore.Save(&state.Snapshot{ + OpenrestyStatus: protocol.OpenrestyStatusUnhealthy, + OpenrestyMessage: "docker run openresty failed: bind 80 already allocated", + }); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + heartbeatService := &fakeHeartbeatService{ + heartbeatResults: []*protocol.HeartbeatResult{{ + AgentSettings: &protocol.AgentSettings{RestartOpenrestyNow: true}, + }}, + onHeartbeat: func(callCount int) { + if callCount >= 1 { + cancel() + } + }, + } + runtimeManager := &fakeRuntimeManager{ + healthErr: errors.New("docker openresty container is not running"), + clearHealthOnRestart: true, + } + runner := withHeartbeatCycle(&Runner{ + Config: &config.Config{ + AccessToken: "agent-token", + NodeName: "edge-01", + NodeIP: "10.0.0.8", + NodeIPConfigured: true, + Version: config.Version, + ExtVersion: "1.27.1.2", + HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), + }, + StateStore: stateStore, + HeartbeatService: heartbeatService, + SyncService: &fakeSyncService{}, + RuntimeManager: runtimeManager, + }, nil) + + err := runner.Run(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + if len(heartbeatService.heartbeatPayloads) == 0 { + t.Fatal("expected at least one heartbeat payload") + } + payload := heartbeatService.heartbeatPayloads[0] + if payload.OpenrestyStatus != protocol.OpenrestyStatusUnhealthy { + t.Fatalf("expected unhealthy openresty status in heartbeat payload, got %q", payload.OpenrestyStatus) + } + if payload.OpenrestyMessage != "docker run openresty failed: bind 80 already allocated" { + t.Fatalf("unexpected openresty message: %q", payload.OpenrestyMessage) + } + if runtimeManager.restartCalls != 1 { + t.Fatalf("expected one openresty restart attempt, got %d", runtimeManager.restartCalls) + } + snapshot, loadErr := stateStore.Load() + if loadErr != nil { + t.Fatalf("failed to load state: %v", loadErr) + } + if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy || snapshot.OpenrestyMessage != "" { + t.Fatal("expected restart success to mark openresty healthy") + } +} + +func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) { + tempDir := t.TempDir() + stateStore := state.NewStore(filepath.Join(tempDir, "state.json")) + if err := stateStore.Save(&state.Snapshot{ + NodeID: "node-observe", + CurrentVersion: "20260314-001", + LastError: "sync failed", + OpenrestyStatus: protocol.OpenrestyStatusUnhealthy, + OpenrestyMessage: "reload failed", + }); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + runner := withHeartbeatCycle(&Runner{ + Config: &config.Config{ + NodeName: "edge-observe-1", + NodeIP: "10.0.0.51", + NodeIPConfigured: true, + Version: config.Version, + ExtVersion: "1.27.1.2", + DataDir: tempDir, + RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"), + AccessLogPath: filepath.Join(tempDir, "var", "log", "openflare", "access.log"), + HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), + }, + StateStore: stateStore, + }, nil) + if err := os.MkdirAll(filepath.Dir(runner.Config.AccessLogPath), 0o755); err != nil { + t.Fatalf("failed to prepare access log dir: %v", err) + } + if err := os.WriteFile( + runner.Config.AccessLogPath, + []byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"), + 0o644, + ); err != nil { + t.Fatalf("failed to prepare access log: %v", err) + } + + firstPayload := runner.HeartbeatCycle.NodePayload(context.Background(), "node-observe") + if firstPayload.Profile == nil { + t.Fatal("expected first heartbeat payload to include system profile") + } + if firstPayload.HostMetrics == nil { + t.Fatal("expected first heartbeat payload to include host metrics") + } + if firstPayload.SchemaVersion != 2 { + t.Fatalf("expected schema_version 2, got %d", firstPayload.SchemaVersion) + } + if len(firstPayload.AccessLogs) != 1 || firstPayload.AccessLogs[0].Path != "/" { + t.Fatalf("expected first heartbeat payload to include access logs, got %+v", firstPayload.AccessLogs) + } + if len(firstPayload.HealthEvents) != 2 { + t.Fatalf("expected health events for openresty and sync error, got %+v", firstPayload.HealthEvents) + } + + secondPayload := runner.HeartbeatCycle.NodePayload(context.Background(), "node-observe") + if secondPayload.Profile != nil { + t.Fatal("expected unchanged profile to be omitted on subsequent heartbeat") + } + if secondPayload.HostMetrics == nil { + t.Fatal("expected host metrics to continue reporting on subsequent heartbeat") + } + if len(secondPayload.AccessLogs) != 0 { + t.Fatalf("expected unchanged access log delta to be omitted on subsequent heartbeat, got %+v", secondPayload.AccessLogs) + } +} + +func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tempDir := t.TempDir() + stateStore := state.NewStore(filepath.Join(tempDir, "state.json")) + bufferStore := state.NewObservabilityBufferStore(filepath.Join(tempDir, "observability-buffer.json")) + nowUnix := time.Now().UTC().Unix() + bufferWindow := nowUnix - (nowUnix % 60) - 60 + if err := bufferStore.Upsert(state.ObservabilityBufferRecord{ + WindowStartedAtUnix: bufferWindow, + HostMetrics: &protocol.NodeMetricSnapshot{CapturedAtUnix: bufferWindow + 5, CPUUsagePercent: 30}, + EdgeHealth: &protocol.NodeEdgeHealth{CapturedAtUnix: bufferWindow + 5, Connections: 3, Status: "healthy"}, + QueuedAtUnix: bufferWindow + 60, + }, 0); err != nil { + t.Fatalf("failed to seed observability buffer: %v", err) + } + heartbeatService := &fakeHeartbeatService{ + heartbeatErrs: []error{errors.New("server offline"), nil}, + heartbeatResults: []*protocol.HeartbeatResult{{}, {}}, + onHeartbeat: func(callCount int) { + if callCount >= 2 { + cancel() + } + }, + } + runner := withHeartbeatCycle(&Runner{ + Config: &config.Config{ + AccessToken: "agent-token", + NodeName: "edge-buffer-01", + NodeIP: "10.0.0.52", + NodeIPConfigured: true, + Version: config.Version, + ExtVersion: "1.27.1.2", + DataDir: tempDir, + RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"), + HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), + ObservabilityReplayMinutes: 15, + }, + StateStore: stateStore, + HeartbeatService: heartbeatService, + SyncService: &fakeSyncService{}, + }, bufferStore) + if err := os.MkdirAll(filepath.Dir(runner.Config.RouteConfigPath), 0o755); err != nil { + t.Fatalf("failed to prepare route config dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "openflare_access.log"), + []byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"), + 0o644, + ); err != nil { + t.Fatalf("failed to prepare access log: %v", err) + } + + runErr := runner.Run(ctx) + if runErr != context.Canceled { + t.Fatalf("expected run to stop by context cancellation, got %v", runErr) + } + if len(heartbeatService.heartbeatPayloads) != 2 { + t.Fatalf("expected two heartbeat payloads, got %d", len(heartbeatService.heartbeatPayloads)) + } + secondPayload := heartbeatService.heartbeatPayloads[1] + if len(secondPayload.Buffered) != 1 { + t.Fatalf("expected second heartbeat to replay one buffered observation, got %+v", secondPayload.Buffered) + } + if len(secondPayload.Buffered[0].AccessLogs) != 0 { + t.Fatalf("expected seeded buffered observation to keep empty access logs, got %+v", secondPayload.Buffered[0].AccessLogs) + } + if secondPayload.Buffered[0].EdgeHealth == nil || secondPayload.Buffered[0].EdgeHealth.Connections != 3 { + t.Fatalf("expected buffered edge health, got %+v", secondPayload.Buffered[0].EdgeHealth) + } + + replayable, err := bufferStore.Replayable(0, 0) + if err != nil { + t.Fatalf("Replayable after recovery failed: %v", err) + } + if len(replayable) != 0 { + t.Fatalf("expected buffer to be acked after successful heartbeat, got %+v", replayable) + } +} + +func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + heartbeatService := &fakeHeartbeatService{ + registerResp: &protocol.RegisterNodeResponse{ + NodeID: "node-server-assigned", + AccessToken: "agent-token-issued", + Name: "edge-01", + }, + heartbeatResults: []*protocol.HeartbeatResult{{}}, + onHeartbeat: func(callCount int) { + if callCount >= 1 { + cancel() + } + }, + } + syncService := &fakeSyncService{} + configPath := filepath.Join(t.TempDir(), "agent.json") + if err := os.WriteFile(configPath, []byte(`{"server_url":"http://127.0.0.1:3000","discovery_token":"discovery-token","node_name":"edge-01","node_ip":"10.0.0.8"}`), 0o644); err != nil { + t.Fatalf("failed to seed config file: %v", err) + } + cfg, err := config.Load(configPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + runner := withHeartbeatCycle(&Runner{ + Config: &config.Config{ + ServerURL: cfg.ServerURL, + DiscoveryToken: cfg.DiscoveryToken, + NodeName: cfg.NodeName, + NodeIP: cfg.NodeIP, + NodeIPConfigured: cfg.NodeIPConfigured, + Version: config.Version, + ExtVersion: "1.27.1.2", + HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), + }, + StateStore: stateStore, + HeartbeatService: heartbeatService, + SyncService: syncService, + }, nil) + runner.Config = cfg + runner.Config.Version = config.Version + runner.Config.ExtVersion = "1.27.1.2" + runner.Config.HeartbeatInterval = config.MillisecondDuration(10 * time.Millisecond) + + err = runner.Run(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + if heartbeatService.registerCalls == 0 { + t.Fatal("expected discovery register to be attempted") + } + if heartbeatService.lastToken != "agent-token-issued" { + t.Fatalf("expected client token to be updated, got %q", heartbeatService.lastToken) + } + snapshot, loadErr := stateStore.Load() + if loadErr != nil { + t.Fatalf("failed to load state: %v", loadErr) + } + if snapshot.NodeID != "node-server-assigned" { + t.Fatalf("expected node id to be replaced, got %q", snapshot.NodeID) + } + if runner.Config.AccessToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" { + t.Fatal("expected config token rotation to complete") + } +} + +func TestRunnerHandlesWebSocketActiveConfigMessage(t *testing.T) { + syncService := &fakeSyncService{} + runner := withHeartbeatCycle(&Runner{SyncService: syncService}, nil) + payload, err := json.Marshal(protocol.ActiveConfigMeta{ + Version: "20260529-001", + Checksum: "checksum-ws", + }) + if err != nil { + t.Fatalf("marshal active config: %v", err) + } + + changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{ + Type: protocol.WSMessageTypeActiveConfig, + Payload: payload, + }, &fakeWebSocketConnection{}) + if err != nil { + t.Fatalf("handle websocket active config: %v", err) + } + if changed { + t.Fatal("active config message should not change heartbeat interval") + } + if syncService.syncOnceCalls != 1 { + t.Fatalf("expected one sync call, got %d", syncService.syncOnceCalls) + } + if syncService.lastTarget == nil || syncService.lastTarget.Version != "20260529-001" || syncService.lastTarget.Checksum != "checksum-ws" { + t.Fatalf("unexpected sync target: %+v", syncService.lastTarget) + } +} + +func TestRunnerHandlesWebSocketSettingsDisabled(t *testing.T) { + runner := withHeartbeatCycle(&Runner{ + Config: &config.Config{ + HeartbeatInterval: config.MillisecondDuration(10 * time.Second), + }, + websocketUpgradeEnabled: true, + }, nil) + payload, err := json.Marshal(protocol.AgentSettings{ + HeartbeatInterval: 15000, + WebsocketUpgradeEnabled: false, + }) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + + changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{ + Type: protocol.WSMessageTypeSettings, + Payload: payload, + }, &fakeWebSocketConnection{}) + if err == nil { + t.Fatal("expected disabled websocket setting to request fallback") + } + if !changed { + t.Fatal("expected heartbeat interval change to be reported") + } + if runner.websocketUpgradeEnabled { + t.Fatal("expected websocket upgrade to be disabled") + } +} + +func TestWebSocketBackoffSequence(t *testing.T) { + backoff := newWebSocketBackoff() + expected := []time.Duration{ + time.Second, + 2 * time.Second, + 5 * time.Second, + 10 * time.Second, + 30 * time.Second, + 30 * time.Second, + } + for _, want := range expected { + if got := backoff.Next(); got != want { + t.Fatalf("unexpected backoff: got %s want %s", got, want) + } + } + backoff.Reset() + if got := backoff.Next(); got != time.Second { + t.Fatalf("expected reset backoff to return 1s, got %s", got) + } +} diff --git a/backend/openflare/plugins/agent/config/config.go b/backend/openflare/plugins/agent/config/config.go new file mode 100644 index 00000000..374e030d --- /dev/null +++ b/backend/openflare/plugins/agent/config/config.go @@ -0,0 +1,418 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package config loads and persists agent daemon configuration. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + pathpkg "path" + "path/filepath" + "strconv" + "strings" + "time" + + "Wavelet/openflare/share/edge/nodeip" + "Wavelet/openflare/share/ofutil" +) + +const ( + defaultMainConfigRelativePath = "etc/nginx/nginx.conf" + defaultRouteConfigRelativePath = "etc/nginx/conf.d/openflare_routes.conf" + defaultCertDirRelativePath = "etc/nginx/certs" + defaultLuaDirRelativePath = "etc/nginx/lua" + defaultRuntimeConfigDirRelativePath = "etc/openflare" + defaultPagesDirRelativePath = "var/lib/openflare/pages" + defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb" + defaultCityMMDBRelativePath = "etc/openflare/GeoLite2-City.mmdb" + defaultAccessLogRelativePath = "var/log/openflare/access.log" + defaultStateRelativePath = "var/lib/openflare/agent-state.json" + defaultObservabilityBufferRelativePath = "var/lib/openflare/observability-buffer.json" + defaultOpenRestyObservabilityPort = 18081 + defaultObservabilityReplayMinutes = 60 + defaultMMDBUpdateInterval = 24 * time.Hour + defaultMMDBDownloadURL = "https://github.com/FyraLabs/geolite2/releases/latest/download/GeoLite2-Country.mmdb" + defaultCityMMDBDownloadURL = "https://github.com/FyraLabs/geolite2/releases/latest/download/GeoLite2-City.mmdb" + defaultHeartbeatInterval = 3 * time.Second + defaultRequestTimeout = 10 * time.Second + configFilePerm = 0o600 +) + +// Config holds the full runtime configuration for the OpenFlare agent. +type Config struct { + ServerURL string `json:"server_url"` + AccessToken string `json:"agent_token"` + DiscoveryToken string `json:"discovery_token"` + NodeName string `json:"node_name"` + NodeIP string `json:"node_ip"` + Version string `json:"-"` + ExtVersion string `json:"-"` + OpenrestyPath string `json:"openresty_path"` + OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"` + DataDir string `json:"data_dir"` + MainConfigPath string `json:"main_config_path"` + RouteConfigPath string `json:"route_config_path"` + AccessLogPath string `json:"access_log_path"` + CertDir string `json:"cert_dir"` + OpenrestyCertDir string `json:"openresty_cert_dir"` + LuaDir string `json:"lua_dir"` + OpenrestyLuaDir string `json:"openresty_lua_dir"` + RuntimeConfigDir string `json:"runtime_config_dir"` + PagesDir string `json:"pages_dir"` + MMDBPath string `json:"mmdb_path"` + CityMMDBPath string `json:"city_mmdb_path"` + MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"` + MMDBDownloadURL string `json:"mmdb_download_url"` + CityMMDBDownloadURL string `json:"city_mmdb_download_url"` + OpenrestyObservabilityPort int `json:"openresty_observability_port"` + ObservabilityBufferPath string `json:"observability_buffer_path"` + ObservabilityReplayMinutes int `json:"observability_replay_minutes"` + StatePath string `json:"state_path"` + HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` + RequestTimeout MillisecondDuration `json:"request_timeout"` + configPath string `json:"-"` + NodeIPConfigured bool `json:"-"` +} + +type configFile struct { + ServerURL string `json:"server_url"` + AccessToken string `json:"agent_token"` + DiscoveryToken string `json:"discovery_token"` + NodeName string `json:"node_name"` + NodeIP string `json:"node_ip"` + OpenrestyPath string `json:"openresty_path"` + OpenrestyResolvers []string `json:"openresty_resolvers"` + DataDir string `json:"data_dir"` + MainConfigPath string `json:"main_config_path"` + RouteConfigPath string `json:"route_config_path"` + AccessLogPath string `json:"access_log_path"` + CertDir string `json:"cert_dir"` + OpenrestyCertDir string `json:"openresty_cert_dir"` + LuaDir string `json:"lua_dir"` + OpenrestyLuaDir string `json:"openresty_lua_dir"` + RuntimeConfigDir string `json:"runtime_config_dir"` + PagesDir string `json:"pages_dir"` + MMDBPath string `json:"mmdb_path"` + CityMMDBPath string `json:"city_mmdb_path"` + MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"` + MMDBDownloadURL string `json:"mmdb_download_url"` + CityMMDBDownloadURL string `json:"city_mmdb_download_url"` + OpenrestyObservabilityPort int `json:"openresty_observability_port"` + ObservabilityBufferPath string `json:"observability_buffer_path"` + ObservabilityReplayMinutes int `json:"observability_replay_minutes"` + StatePath string `json:"state_path"` + HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` + RequestTimeout MillisecondDuration `json:"request_timeout"` +} + +func transferPersistedConfig(dst, src any) error { + data, err := json.Marshal(src) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} + +// Load reads and parses the agent configuration file at the given path. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is the configured agent config location + if err != nil && !os.IsNotExist(err) { + return nil, err + } + file := &configFile{} + if err == nil { + if err = json.Unmarshal(data, file); err != nil { + return nil, err + } + } + if err != nil && !hasEnvConfig() { + return nil, err + } + cfg := &Config{} + if err == nil { + if err = transferPersistedConfig(cfg, file); err != nil { + return nil, err + } + } + cfg.configPath = path + applyEnvOverrides(cfg) + cfg.NodeIPConfigured = cfg.NodeIP != "" + applyDefaults(cfg, filepath.Dir(path)) + if err = validate(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func applyDefaults(cfg *Config, baseDir string) { + baseDir = filepath.Clean(baseDir) + cfg.Version = Version + cfg.OpenrestyResolvers = ofutil.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers) + applyAgentIdentityDefaults(cfg) + applyAgentPathDefaults(cfg, baseDir) + applyAgentTimingDefaults(cfg) + normalizeManagedPaths(cfg) +} + +func applyAgentIdentityDefaults(cfg *Config) { + if cfg.OpenrestyPath == "" { + cfg.OpenrestyPath = "openresty" + } + if cfg.NodeName == "" { + cfg.NodeName = detectHostname() + } + if cfg.NodeIP == "" { + cfg.NodeIP = nodeip.Detect() + } +} + +func applyAgentPathDefaults(cfg *Config, baseDir string) { + if cfg.DataDir == "" { + cfg.DataDir = filepath.Join(baseDir, "data") + } + type managedPathDefault struct { + target *string + relative string + } + pathDefaults := []managedPathDefault{ + {&cfg.MainConfigPath, defaultMainConfigRelativePath}, + {&cfg.RouteConfigPath, defaultRouteConfigRelativePath}, + {&cfg.AccessLogPath, defaultAccessLogRelativePath}, + {&cfg.StatePath, defaultStateRelativePath}, + {&cfg.CertDir, defaultCertDirRelativePath}, + {&cfg.LuaDir, defaultLuaDirRelativePath}, + {&cfg.RuntimeConfigDir, defaultRuntimeConfigDirRelativePath}, + {&cfg.PagesDir, defaultPagesDirRelativePath}, + {&cfg.MMDBPath, defaultMMDBRelativePath}, + {&cfg.CityMMDBPath, defaultCityMMDBRelativePath}, + {&cfg.ObservabilityBufferPath, defaultObservabilityBufferRelativePath}, + } + for _, item := range pathDefaults { + if strings.TrimSpace(*item.target) == "" { + *item.target = joinManagedPath(cfg.DataDir, item.relative) + } + } + if cfg.OpenrestyCertDir == "" { + cfg.OpenrestyCertDir = cfg.CertDir + } + if cfg.OpenrestyLuaDir == "" { + cfg.OpenrestyLuaDir = cfg.LuaDir + } +} + +func applyAgentTimingDefaults(cfg *Config) { + if cfg.MMDBUpdateInterval <= 0 { + cfg.MMDBUpdateInterval = MillisecondDuration(defaultMMDBUpdateInterval) + } + if cfg.MMDBDownloadURL == "" { + cfg.MMDBDownloadURL = defaultMMDBDownloadURL + } + if cfg.CityMMDBDownloadURL == "" { + cfg.CityMMDBDownloadURL = defaultCityMMDBDownloadURL + } + if cfg.OpenrestyObservabilityPort <= 0 { + cfg.OpenrestyObservabilityPort = defaultOpenRestyObservabilityPort + } + if cfg.ObservabilityReplayMinutes <= 0 { + cfg.ObservabilityReplayMinutes = defaultObservabilityReplayMinutes + } + if cfg.HeartbeatInterval <= 0 { + cfg.HeartbeatInterval = MillisecondDuration(defaultHeartbeatInterval) + } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = MillisecondDuration(defaultRequestTimeout) + } +} + +func normalizeManagedPaths(cfg *Config) { + if cfg == nil { + return + } + paths := []*string{ + &cfg.DataDir, + &cfg.MainConfigPath, + &cfg.RouteConfigPath, + &cfg.AccessLogPath, + &cfg.CertDir, + &cfg.OpenrestyCertDir, + &cfg.LuaDir, + &cfg.OpenrestyLuaDir, + &cfg.RuntimeConfigDir, + &cfg.PagesDir, + &cfg.StatePath, + &cfg.ObservabilityBufferPath, + &cfg.MMDBPath, + &cfg.CityMMDBPath, + } + for _, p := range paths { + if usesSlashPath(*p) { + *p = filepath.ToSlash(*p) + } + } +} + +func hasEnvConfig() bool { + for _, key := range []string{ + "OPENFLARE_SERVER_URL", + "OPENFLARE_AGENT_TOKEN", + "OPENFLARE_DISCOVERY_TOKEN", + "OPENFLARE_NODE_NAME", + "OPENFLARE_NODE_IP", + "OPENFLARE_DATA_DIR", + "OPENFLARE_OPENRESTY_PATH", + "OPENFLARE_PAGES_DIR", + "OPENFLARE_HEARTBEAT_INTERVAL", + "OPENFLARE_REQUEST_TIMEOUT", + "OPENFLARE_OPENRESTY_OBSERVABILITY_PORT", + "OPENFLARE_MMDB_PATH", + "OPENFLARE_MMDB_UPDATE_INTERVAL", + "OPENFLARE_MMDB_DOWNLOAD_URL", + "OPENFLARE_CITY_MMDB_PATH", + "OPENFLARE_CITY_MMDB_DOWNLOAD_URL", + } { + if strings.TrimSpace(os.Getenv(key)) != "" { + return true + } + } + return false +} + +func applyEnvOverrides(cfg *Config) { + if cfg == nil { + return + } + overrideString := func(key string, target *string) { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + *target = value + } + } + overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL) + overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AccessToken) + overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken) + overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName) + overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP) + overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir) + overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath) + overrideString("OPENFLARE_PAGES_DIR", &cfg.PagesDir) + overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath) + overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL) + overrideString("OPENFLARE_CITY_MMDB_PATH", &cfg.CityMMDBPath) + overrideString("OPENFLARE_CITY_MMDB_DOWNLOAD_URL", &cfg.CityMMDBDownloadURL) + if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" { + if duration, err := parseDurationValue(value); err == nil { + cfg.HeartbeatInterval = duration + } + } + if value := strings.TrimSpace(os.Getenv("OPENFLARE_REQUEST_TIMEOUT")); value != "" { + if duration, err := parseDurationValue(value); err == nil { + cfg.RequestTimeout = duration + } + } + if value := strings.TrimSpace(os.Getenv("OPENFLARE_MMDB_UPDATE_INTERVAL")); value != "" { + if duration, err := parseDurationValue(value); err == nil { + cfg.MMDBUpdateInterval = duration + } + } + if value := strings.TrimSpace(os.Getenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT")); value != "" { + var port int + if _, err := fmt.Sscanf(value, "%d", &port); err == nil { + cfg.OpenrestyObservabilityPort = port + } + } +} + +func parseDurationValue(value string) (MillisecondDuration, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, nil + } + if parsed, err := time.ParseDuration(trimmed); err == nil { + return MillisecondDuration(parsed), nil + } + ms, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, err + } + return MillisecondDuration(time.Duration(ms) * time.Millisecond), nil +} + +func usesSlashPath(path string) bool { + return strings.HasPrefix(path, "/") +} + +func joinManagedPath(base string, relative string) string { + if usesSlashPath(base) { + return pathpkg.Join(filepath.ToSlash(base), relative) + } + return filepath.Join(base, relative) +} + +func validate(cfg *Config) error { + if cfg.ServerURL == "" { + return errors.New("server_url 不能为空") + } + if strings.TrimSpace(cfg.AccessToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" { + return errors.New("agent_token 和 discovery_token 不能同时为空") + } + if cfg.NodeName == "" { + return errors.New("node_name 不能为空") + } + if cfg.NodeIP == "" { + return errors.New("node_ip 不能为空") + } + if cfg.OpenrestyObservabilityPort <= 0 || cfg.OpenrestyObservabilityPort > 65535 { + return errors.New("openresty_observability_port 必须在 1-65535 之间") + } + if cfg.ObservabilityReplayMinutes <= 0 { + return errors.New("observability_replay_minutes 必须大于 0") + } + if cfg.MMDBUpdateInterval <= 0 { + return errors.New("mmdb_update_interval 必须大于 0") + } + return nil +} + +// InitialAuthToken returns the agent access token, falling back to the discovery token if absent. +func (cfg *Config) InitialAuthToken() string { + if cfg == nil { + return "" + } + if token := strings.TrimSpace(cfg.AccessToken); token != "" { + return token + } + return strings.TrimSpace(cfg.DiscoveryToken) +} + +func (cfg *Config) toConfigFile() configFile { + var file configFile + if err := transferPersistedConfig(&file, cfg); err != nil { + return configFile{} + } + return file +} + +// Save persists the current configuration back to its original file path. +func (cfg *Config) Save() error { + if cfg == nil { + return errors.New("config 不能为空") + } + if cfg.configPath == "" { + return errors.New("config path 未初始化") + } + data, err := json.MarshalIndent(cfg.toConfigFile(), "", " ") //nolint:gosec // agent token must be persisted in local config file + if err != nil { + return err + } + return os.WriteFile(cfg.configPath, data, configFilePerm) +} + +func detectHostname() string { + host, err := os.Hostname() + if err != nil { + return "" + } + return strings.TrimSpace(host) +} diff --git a/backend/openflare/plugins/agent/config/config_test.go b/backend/openflare/plugins/agent/config/config_test.go new file mode 100644 index 00000000..78f70eb0 --- /dev/null +++ b/backend/openflare/plugins/agent/config/config_test.go @@ -0,0 +1,559 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" + + "Wavelet/openflare/share/edge/nodeip" + "Wavelet/openflare/share/geoip" + "Wavelet/openflare/share/geoip/iputil" +) + +func TestLoadDefaultsToManagedBinaryPaths(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + "node_ip": "10.0.0.8", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.DataDir != filepath.Join(dir, "data") { + t.Fatalf("unexpected data dir: %s", cfg.DataDir) + } + if cfg.OpenrestyPath != "openresty" { + t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath) + } + if cfg.MainConfigPath != filepath.Join(dir, "data", defaultMainConfigRelativePath) { + t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath) + } + if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultRouteConfigRelativePath) { + t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath) + } + if cfg.AccessLogPath != filepath.Join(dir, "data", defaultAccessLogRelativePath) { + t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath) + } + if cfg.CertDir != filepath.Join(dir, "data", defaultCertDirRelativePath) { + t.Fatalf("unexpected cert dir: %s", cfg.CertDir) + } + if cfg.LuaDir != filepath.Join(dir, "data", defaultLuaDirRelativePath) { + t.Fatalf("unexpected lua dir: %s", cfg.LuaDir) + } + if cfg.RuntimeConfigDir != filepath.Join(dir, "data", defaultRuntimeConfigDirRelativePath) { + t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir) + } + if cfg.CityMMDBPath != filepath.Join(dir, "data", defaultCityMMDBRelativePath) { + t.Fatalf("unexpected city mmdb path: %s", cfg.CityMMDBPath) + } + if cfg.CityMMDBDownloadURL != defaultCityMMDBDownloadURL { + t.Fatalf("unexpected city mmdb download URL: %s", cfg.CityMMDBDownloadURL) + } + if cfg.OpenrestyCertDir != cfg.CertDir { + t.Fatalf("unexpected openresty cert dir: %s", cfg.OpenrestyCertDir) + } + if cfg.OpenrestyLuaDir != cfg.LuaDir { + t.Fatalf("unexpected openresty lua dir: %s", cfg.OpenrestyLuaDir) + } + if cfg.StatePath != filepath.Join(dir, "data", defaultStateRelativePath) { + t.Fatalf("unexpected state path: %s", cfg.StatePath) + } + if cfg.ObservabilityBufferPath != filepath.Join(dir, "data", defaultObservabilityBufferRelativePath) { + t.Fatalf("unexpected observability buffer path: %s", cfg.ObservabilityBufferPath) + } + if cfg.OpenrestyObservabilityPort != defaultOpenRestyObservabilityPort { + t.Fatalf("unexpected openresty observability port: %d", cfg.OpenrestyObservabilityPort) + } + if cfg.ObservabilityReplayMinutes != defaultObservabilityReplayMinutes { + t.Fatalf("unexpected observability replay minutes: %d", cfg.ObservabilityReplayMinutes) + } +} + +func TestLoadPathModeKeepsExplicitPaths(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + "node_ip": "10.0.0.8", + "openresty_path": "/usr/local/openresty/nginx/sbin/openresty", + "main_config_path": "/tmp/nginx.conf", + "route_config_path": "/tmp/routes.conf", + "state_path": "/tmp/agent-state.json", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.MainConfigPath != "/tmp/nginx.conf" { + t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath) + } + if cfg.RouteConfigPath != "/tmp/routes.conf" { + t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath) + } + if cfg.StatePath != "/tmp/agent-state.json" { + t.Fatalf("unexpected state path: %s", cfg.StatePath) + } + if cfg.ObservabilityBufferPath != filepath.Join(dir, "data", defaultObservabilityBufferRelativePath) { + t.Fatalf("unexpected observability buffer path: %s", cfg.ObservabilityBufferPath) + } + if cfg.OpenrestyCertDir != cfg.CertDir { + t.Fatalf("expected path mode openresty cert dir to equal cert dir, got %s / %s", cfg.OpenrestyCertDir, cfg.CertDir) + } + if cfg.OpenrestyLuaDir != cfg.LuaDir { + t.Fatalf("expected path mode openresty lua dir to equal lua dir, got %s / %s", cfg.OpenrestyLuaDir, cfg.LuaDir) + } + if cfg.OpenrestyObservabilityPort != defaultOpenRestyObservabilityPort { + t.Fatalf("unexpected path mode openresty observability port: %d", cfg.OpenrestyObservabilityPort) + } +} + +func TestLoadNormalizesExplicitResolvers(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + "node_ip": "10.0.0.8", + "openresty_resolvers": []string{" 10.0.0.2 ", "10.0.0.2", "", "1.1.1.1"}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + expected := []string{"10.0.0.2", "1.1.1.1"} + if len(cfg.OpenrestyResolvers) != len(expected) { + t.Fatalf("unexpected resolver count: %#v", cfg.OpenrestyResolvers) + } + for index, value := range expected { + if cfg.OpenrestyResolvers[index] != value { + t.Fatalf("unexpected resolver at %d: got %q want %q", index, cfg.OpenrestyResolvers[index], value) + } + } +} + +func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + "node_ip": "10.0.0.8", + "data_dir": "/srv/openflare", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.RouteConfigPath != "/srv/openflare/"+defaultRouteConfigRelativePath { + t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath) + } + if cfg.MainConfigPath != "/srv/openflare/"+defaultMainConfigRelativePath { + t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath) + } + if cfg.AccessLogPath != "/srv/openflare/"+defaultAccessLogRelativePath { + t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath) + } + if cfg.StatePath != "/srv/openflare/"+defaultStateRelativePath { + t.Fatalf("unexpected state path: %s", cfg.StatePath) + } + if cfg.ObservabilityBufferPath != "/srv/openflare/"+defaultObservabilityBufferRelativePath { + t.Fatalf("unexpected observability buffer path: %s", cfg.ObservabilityBufferPath) + } + if cfg.CertDir != "/srv/openflare/"+defaultCertDirRelativePath { + t.Fatalf("unexpected cert dir: %s", cfg.CertDir) + } + if cfg.LuaDir != "/srv/openflare/"+defaultLuaDirRelativePath { + t.Fatalf("unexpected lua dir: %s", cfg.LuaDir) + } + if cfg.RuntimeConfigDir != "/srv/openflare/"+defaultRuntimeConfigDirRelativePath { + t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir) + } +} + +func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) { + dir := t.TempDir() + t.Setenv("OPENFLARE_SERVER_URL", "http://127.0.0.1:3000") + t.Setenv("OPENFLARE_AGENT_TOKEN", "token") + t.Setenv("OPENFLARE_NODE_NAME", "edge-env") + t.Setenv("OPENFLARE_NODE_IP", "10.0.0.9") + t.Setenv("OPENFLARE_DATA_DIR", "/srv/openflare-env") + t.Setenv("OPENFLARE_OPENRESTY_PATH", "/usr/bin/openresty") + t.Setenv("OPENFLARE_HEARTBEAT_INTERVAL", "45s") + t.Setenv("OPENFLARE_REQUEST_TIMEOUT", "2500") + t.Setenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT", "19091") + + cfg, err := Load(filepath.Join(dir, "missing-agent.json")) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AccessToken != "token" { + t.Fatalf("unexpected env auth config: %#v", cfg) + } + if cfg.OpenrestyPath != "/usr/bin/openresty" { + t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath) + } + if cfg.DataDir != "/srv/openflare-env" { + t.Fatalf("unexpected data dir: %s", cfg.DataDir) + } + if cfg.HeartbeatInterval.Duration() != 45*time.Second { + t.Fatalf("unexpected heartbeat interval: %s", cfg.HeartbeatInterval) + } + if cfg.RequestTimeout.Duration() != 2500*time.Millisecond { + t.Fatalf("unexpected request timeout: %s", cfg.RequestTimeout) + } + if cfg.OpenrestyObservabilityPort != 19091 { + t.Fatalf("unexpected observability port: %d", cfg.OpenrestyObservabilityPort) + } +} + +func TestLoadDetectsOutboundIPWhenNodeIPMissing(t *testing.T) { + nodeip.ResetCacheForTest() + previousLookup := nodeip.LookupOutboundIP + nodeip.LookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) { + return net.ParseIP("8.8.8.8"), nil + } + defer func() { + nodeip.LookupOutboundIP = previousLookup + nodeip.ResetCacheForTest() + }() + + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.NodeIP != "8.8.8.8" { + t.Fatalf("expected outbound IP, got %s", cfg.NodeIP) + } +} + +func TestLoadFallsBackToLocalIPWhenOutboundLookupFails(t *testing.T) { + nodeip.ResetCacheForTest() + previousOutboundLookup := nodeip.LookupOutboundIP + previousLocalLookup := nodeip.LookupLocalIP + nodeip.LookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) { + return nil, errors.New("realip.cc unavailable") + } + nodeip.LookupLocalIP = func() string { + return "9.9.9.9" + } + defer func() { + nodeip.LookupOutboundIP = previousOutboundLookup + nodeip.LookupLocalIP = previousLocalLookup + nodeip.ResetCacheForTest() + }() + + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.NodeIP != "9.9.9.9" { + t.Fatalf("expected local fallback IP, got %s", cfg.NodeIP) + } +} + +func TestLoadEnvOverridesConfigFile(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + if err := os.WriteFile(configPath, []byte(`{"server_url":"http://old:3000","agent_token":"old","node_name":"edge-01","node_ip":"10.0.0.8","openresty_path":"/old/openresty"}`), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + t.Setenv("OPENFLARE_SERVER_URL", "http://new:3000") + t.Setenv("OPENFLARE_AGENT_TOKEN", "new-token") + t.Setenv("OPENFLARE_OPENRESTY_PATH", "/new/openresty") + t.Setenv("OPENFLARE_CITY_MMDB_PATH", "/new/GeoLite2-City.mmdb") + t.Setenv("OPENFLARE_CITY_MMDB_DOWNLOAD_URL", "https://geo.example/GeoLite2-City.mmdb") + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.ServerURL != "http://new:3000" { + t.Fatalf("expected server url from env, got %s", cfg.ServerURL) + } + if cfg.AccessToken != "new-token" { + t.Fatalf("expected token from env, got %s", cfg.AccessToken) + } + if cfg.OpenrestyPath != "/new/openresty" { + t.Fatalf("expected openresty path from env, got %s", cfg.OpenrestyPath) + } + if cfg.CityMMDBPath != "/new/GeoLite2-City.mmdb" || cfg.CityMMDBDownloadURL != "https://geo.example/GeoLite2-City.mmdb" { + t.Fatalf("unexpected City MMDB env overrides: %s / %s", cfg.CityMMDBPath, cfg.CityMMDBDownloadURL) + } +} + +func TestLoadKeepsExplicitCityMMDBConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := `{"server_url":"http://127.0.0.1:3000","agent_token":"token","node_name":"edge-01","node_ip":"10.0.0.8","city_mmdb_path":"/custom/GeoLite2-City.mmdb","city_mmdb_download_url":"https://custom.example/GeoLite2-City.mmdb"}` + if err := os.WriteFile(configPath, []byte(payload), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.CityMMDBPath != "/custom/GeoLite2-City.mmdb" || cfg.CityMMDBDownloadURL != "https://custom.example/GeoLite2-City.mmdb" { + t.Fatalf("explicit City MMDB config changed: %s / %s", cfg.CityMMDBPath, cfg.CityMMDBDownloadURL) + } +} + +func TestLoadUsesMillisecondsForIntervals(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + "node_ip": "10.0.0.8", + "heartbeat_interval": 30000, + "request_timeout": 1500, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.HeartbeatInterval.Duration() != 30*time.Second { + t.Fatalf("unexpected heartbeat interval: %s", cfg.HeartbeatInterval) + } + if cfg.RequestTimeout.Duration() != 1500*time.Millisecond { + t.Fatalf("unexpected request timeout: %s", cfg.RequestTimeout) + } +} + +func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + if err := os.WriteFile(configPath, []byte(`{"server_url":"http://127.0.0.1:3000","agent_token":"token","node_name":"edge-01","node_ip":"10.0.0.8"}`), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + cfg.ExtVersion = "1.27.1.2" + cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second) + cfg.RequestTimeout = MillisecondDuration(7 * time.Second) + cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"} + + if err = cfg.Save(); err != nil { + t.Fatalf("Save failed: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read saved config: %v", err) + } + var decoded map[string]any + if err = json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to decode saved config: %v", err) + } + if _, ok := decoded["agent_version"]; ok { + t.Fatal("agent_version should not be persisted") + } + if _, ok := decoded["nginx_version"]; ok { + t.Fatal("nginx_version should not be persisted") + } + if decoded["heartbeat_interval"] != float64(5000) { + t.Fatalf("unexpected heartbeat interval: %#v", decoded["heartbeat_interval"]) + } + if decoded["request_timeout"] != float64(7000) { + t.Fatalf("unexpected request timeout: %#v", decoded["request_timeout"]) + } + resolvers, ok := decoded["openresty_resolvers"].([]any) + if !ok || len(resolvers) != 2 || resolvers[0] != "10.0.0.2" || resolvers[1] != "1.1.1.1" { + t.Fatalf("unexpected resolvers: %#v", decoded["openresty_resolvers"]) + } + if decoded["openresty_observability_port"] != float64(defaultOpenRestyObservabilityPort) { + t.Fatalf("unexpected observability port: %#v", decoded["openresty_observability_port"]) + } + if decoded["observability_replay_minutes"] != float64(defaultObservabilityReplayMinutes) { + t.Fatalf("unexpected observability replay minutes: %#v", decoded["observability_replay_minutes"]) + } + if decoded["city_mmdb_path"] != cfg.CityMMDBPath || decoded["city_mmdb_download_url"] != cfg.CityMMDBDownloadURL { + t.Fatalf("City MMDB config was not persisted: %#v", decoded) + } + if _, ok := decoded["nginx_path"]; ok { + t.Fatal("legacy nginx_path should not be persisted") + } +} + +func TestInitialAuthToken(t *testing.T) { + tests := []struct { + name string + agentToken string + discoveryToken string + expected string + }{ + { + name: "prefer agent token", + agentToken: "agent-token", + discoveryToken: "discovery-token", + expected: "agent-token", + }, + { + name: "fallback to discovery token", + agentToken: " ", + discoveryToken: "discovery-token", + expected: "discovery-token", + }, + { + name: "nil config returns empty string", + agentToken: "", + discoveryToken: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cfg *Config + if tt.name != "nil config returns empty string" { + cfg = &Config{ + AccessToken: tt.agentToken, + DiscoveryToken: tt.discoveryToken, + } + } + if token := cfg.InitialAuthToken(); token != tt.expected { + t.Fatalf("unexpected initial auth token: %q", token) + } + }) + } +} + +func TestNodeIPPriority(t *testing.T) { + tests := []struct { + name string + ip string + expected int + }{ + { + name: "public ipv4 preferred", + ip: "8.8.8.8", + expected: 2, + }, + { + name: "private ipv4 fallback", + ip: "10.0.0.8", + expected: 1, + }, + { + name: "link local ignored", + ip: "169.254.1.10", + expected: -1, + }, + { + name: "loopback ignored", + ip: "127.0.0.1", + expected: -1, + }, + { + name: "nil ignored", + ip: "", + expected: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var parsed net.IP + if tt.ip != "" { + parsed = net.ParseIP(tt.ip) + } + if got := iputil.Score(parsed); got != tt.expected { + t.Fatalf("unexpected priority for %q: got %d want %d", tt.ip, got, tt.expected) + } + }) + } +} diff --git a/backend/openflare/plugins/agent/config/duration.go b/backend/openflare/plugins/agent/config/duration.go new file mode 100644 index 00000000..933d6c1e --- /dev/null +++ b/backend/openflare/plugins/agent/config/duration.go @@ -0,0 +1,9 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config + +import edgeconfig "Wavelet/openflare/share/edge/config" + +// MillisecondDuration is an alias for the edge config millisecond-precision duration type. +type MillisecondDuration = edgeconfig.MillisecondDuration diff --git a/backend/openflare/plugins/agent/config/version.go b/backend/openflare/plugins/agent/config/version.go new file mode 100644 index 00000000..1c1e99c8 --- /dev/null +++ b/backend/openflare/plugins/agent/config/version.go @@ -0,0 +1,7 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config + +// Version is the current agent version string, overridden at build time. +var Version = "dev" diff --git a/backend/openflare/plugins/agent/geoipdata/data.go b/backend/openflare/plugins/agent/geoipdata/data.go new file mode 100644 index 00000000..3800e81d --- /dev/null +++ b/backend/openflare/plugins/agent/geoipdata/data.go @@ -0,0 +1,16 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package geoipdata holds shared GeoIP database filename constants. +// +// MaxMind MMDB files are NOT embedded into the agent binary. Docker images +// COPY them onto the default data paths; bare binary installs seed via download +// on first start (see geoipupdate). +package geoipdata + +const ( + // DefaultMMDBName is the default Country database filename. + DefaultMMDBName = "GeoLite2-Country.mmdb" + // DefaultCityMMDBName is the default City database filename. + DefaultCityMMDBName = "GeoLite2-City.mmdb" +) diff --git a/backend/openflare/plugins/agent/geoipupdate/updater.go b/backend/openflare/plugins/agent/geoipupdate/updater.go new file mode 100644 index 00000000..2f08850f --- /dev/null +++ b/backend/openflare/plugins/agent/geoipupdate/updater.go @@ -0,0 +1,139 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package geoipupdate schedules local MaxMind GeoIP database updates for the agent. +package geoipupdate + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "Wavelet/openflare/share/geoip" +) + +// Updater periodically downloads a fresh GeoIP MMDB file and seeds missing +// databases via download (or relies on image-provided files under data_dir). +type Updater struct { + MMDBPath string + DownloadURL string + CityMMDBPath string + CityDownloadURL string + UpdateInterval time.Duration + downloadDatabase func(context.Context, string, string) error +} + +// EnsureInitialDatabases downloads any missing Country/City MMDB once. +// When files already exist (e.g. Docker image COPY), this is a no-op. +// Network is used only when a managed path is absent — not for binary embeds. +func (u *Updater) EnsureInitialDatabases(ctx context.Context) error { + if u == nil { + return nil + } + return u.ensureMissingDatabases(ctx) +} + +func (u *Updater) ensureMissingDatabases(ctx context.Context) error { + databases := u.managedDatabases() + var errs []error + for _, database := range databases { + if database.path == "" || database.downloadURL == "" { + continue + } + exists, err := fileExists(database.path) + if err != nil { + errs = append(errs, fmt.Errorf("stat GeoIP %s mmdb failed: %w", database.name, err)) + continue + } + if exists { + continue + } + if err := u.download(ctx, database.path, database.downloadURL); err != nil { + errs = append(errs, fmt.Errorf("seed GeoIP %s mmdb failed: %w", database.name, err)) + continue + } + slog.Info("seeded GeoIP mmdb via download", "database", database.name, "path", database.path) + } + return errors.Join(errs...) +} + +func fileExists(path string) (bool, error) { + path = filepath.Clean(path) + if path == "" || path == "." { + return false, nil + } + info, err := os.Stat(path) + if err == nil { + if !info.Mode().IsRegular() { + return false, fmt.Errorf("GeoIP MMDB path is not a regular file: %s", path) + } + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func (u *Updater) download(ctx context.Context, path string, downloadURL string) error { + if u.downloadDatabase != nil { + return u.downloadDatabase(ctx, path, downloadURL) + } + return geoip.DownloadMaxMindDatabase(ctx, path, downloadURL) +} + +func (u *Updater) managedDatabases() []struct { + name string + path string + downloadURL string +} { + return []struct { + name string + path string + downloadURL string + }{ + {name: "Country", path: u.MMDBPath, downloadURL: u.DownloadURL}, + {name: "City", path: u.CityMMDBPath, downloadURL: u.CityDownloadURL}, + } +} + +func (u *Updater) updateDatabases(ctx context.Context) error { + var errs []error + for _, database := range u.managedDatabases() { + if database.path == "" || database.downloadURL == "" { + continue + } + if err := u.download(ctx, database.path, database.downloadURL); err != nil { + errs = append(errs, fmt.Errorf("update GeoIP %s mmdb failed: %w", database.name, err)) + continue + } + slog.Info("GeoIP mmdb updated", "database", database.name, "path", database.path) + } + return errors.Join(errs...) +} + +// Run starts the periodic GeoIP update loop and blocks until ctx is cancelled. +func (u *Updater) Run(ctx context.Context) { + if u == nil || u.MMDBPath == "" || u.UpdateInterval <= 0 { + return + } + if err := u.EnsureInitialDatabases(ctx); err != nil { + slog.Warn("initialize GeoIP databases failed", "country_path", u.MMDBPath, "city_path", u.CityMMDBPath, "error", err) + } + ticker := time.NewTicker(u.UpdateInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := u.updateDatabases(ctx); err != nil { + slog.Warn("update GeoIP databases failed", "error", err) + } + } + } +} diff --git a/backend/openflare/plugins/agent/geoipupdate/updater_test.go b/backend/openflare/plugins/agent/geoipupdate/updater_test.go new file mode 100644 index 00000000..7f74d7e7 --- /dev/null +++ b/backend/openflare/plugins/agent/geoipupdate/updater_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package geoipupdate + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestEnsureInitialDatabasesDownloadsMissingOnly(t *testing.T) { + tempDir := t.TempDir() + countryPath := filepath.Join(tempDir, "GeoLite2-Country.mmdb") + cityPath := filepath.Join(tempDir, "GeoLite2-City.mmdb") + if err := os.WriteFile(cityPath, []byte("existing-city"), 0o600); err != nil { + t.Fatal(err) + } + + var downloaded []string + updater := &Updater{ + MMDBPath: countryPath, + DownloadURL: "https://geo.example/GeoLite2-Country.mmdb", + CityMMDBPath: cityPath, + CityDownloadURL: "https://geo.example/GeoLite2-City.mmdb", + downloadDatabase: func(_ context.Context, path, _ string) error { + downloaded = append(downloaded, path) + return os.WriteFile(path, []byte("downloaded"), 0o600) + }, + } + + if err := updater.EnsureInitialDatabases(context.Background()); err != nil { + t.Fatalf("EnsureInitialDatabases failed: %v", err) + } + if !slices.Equal(downloaded, []string{countryPath}) { + t.Fatalf("expected only missing Country download, got %#v", downloaded) + } + if data, err := os.ReadFile(cityPath); err != nil || string(data) != "existing-city" { + t.Fatalf("existing City must stay untouched, data=%q err=%v", data, err) + } + if data, err := os.ReadFile(countryPath); err != nil || string(data) != "downloaded" { + t.Fatalf("Country should be seeded via download, data=%q err=%v", data, err) + } +} + +func TestEnsureInitialDatabasesNoOpWhenPresent(t *testing.T) { + tempDir := t.TempDir() + countryPath := filepath.Join(tempDir, "GeoLite2-Country.mmdb") + cityPath := filepath.Join(tempDir, "GeoLite2-City.mmdb") + if err := os.WriteFile(countryPath, []byte("c"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cityPath, []byte("city"), 0o600); err != nil { + t.Fatal(err) + } + updater := &Updater{ + MMDBPath: countryPath, + DownloadURL: "https://geo.example/GeoLite2-Country.mmdb", + CityMMDBPath: cityPath, + CityDownloadURL: "https://geo.example/GeoLite2-City.mmdb", + downloadDatabase: func(_ context.Context, path, downloadURL string) error { + t.Fatalf("must not download when files exist: %s %s", path, downloadURL) + return nil + }, + } + if err := updater.EnsureInitialDatabases(context.Background()); err != nil { + t.Fatalf("EnsureInitialDatabases failed: %v", err) + } +} + +func TestUpdateDatabasesAttemptsCityAfterCountryFailure(t *testing.T) { + var paths []string + updater := &Updater{ + MMDBPath: "/data/GeoLite2-Country.mmdb", + DownloadURL: "https://geo.example/GeoLite2-Country.mmdb", + CityMMDBPath: "/data/GeoLite2-City.mmdb", + CityDownloadURL: "https://geo.example/GeoLite2-City.mmdb", + downloadDatabase: func(_ context.Context, path, _ string) error { + paths = append(paths, path) + if path == "/data/GeoLite2-Country.mmdb" { + return errors.New("country unavailable") + } + return nil + }, + } + + err := updater.updateDatabases(context.Background()) + if err == nil || !slices.Equal(paths, []string{"/data/GeoLite2-Country.mmdb", "/data/GeoLite2-City.mmdb"}) { + t.Fatalf("expected independent Country then City attempts, paths=%#v err=%v", paths, err) + } +} + +func TestEnsureInitialDatabasesRejectsDirectoryPath(t *testing.T) { + tempDir := t.TempDir() + // Point Country path at a directory so fileExists must not treat it as seeded. + updater := &Updater{ + MMDBPath: tempDir, + DownloadURL: "https://geo.example/GeoLite2-Country.mmdb", + downloadDatabase: func(_ context.Context, path, downloadURL string) error { + t.Fatalf("must not download when path is a directory: %s %s", path, downloadURL) + return nil + }, + } + + err := updater.EnsureInitialDatabases(context.Background()) + if err == nil { + t.Fatal("expected error when MMDB path is a directory") + } + if !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("expected regular-file error, got %v", err) + } +} diff --git a/backend/openflare/plugins/agent/heartbeat/cycle.go b/backend/openflare/plugins/agent/heartbeat/cycle.go new file mode 100644 index 00000000..ef893f75 --- /dev/null +++ b/backend/openflare/plugins/agent/heartbeat/cycle.go @@ -0,0 +1,229 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package heartbeat implements the periodic heartbeat cycle executed by the agent, +// including payload preparation, config sync, WAF IP group application, and observability buffering. +package heartbeat + +import ( + "context" + "log/slog" + "strings" + "time" + + "Wavelet/openflare/plugins/agent/config" + "Wavelet/openflare/plugins/agent/observability" + "Wavelet/openflare/plugins/agent/protocol" + "Wavelet/openflare/plugins/agent/state" + "Wavelet/openflare/plugins/agent/updater" + edgeheartbeat "Wavelet/openflare/share/edge/heartbeat" + "Wavelet/openflare/share/edge/nodeip" +) + +// SyncService is the interface used by Cycle to sync active configuration and WAF IP groups. +type SyncService interface { + SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error + SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error + WAFIPGroupChecksums() (map[string]string, error) + ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error +} + +// SettingsApplier is the interface used by Cycle to apply agent settings received from the server. +type SettingsApplier interface { + Apply(settings *protocol.AgentSettings) (intervalChanged bool) + RestartOpenrestyIfNeeded(ctx context.Context) +} + +// Cycle holds the dependencies required to execute a single agent heartbeat cycle. +type Cycle struct { + Config *config.Config + StateStore *state.Store + ObservabilityBuffer *state.ObservabilityBufferStore + Heartbeat API + Sync SyncService + Updater *updater.Service + RecordSyncError func(err error) +} + +// Perform executes one complete heartbeat cycle: sends the heartbeat, syncs config, and applies settings. +func (c *Cycle) Perform(ctx context.Context, nodeID string, startup bool, settings SettingsApplier) (bool, error) { + payload, ackWindows := c.PrepareHeartbeatPayload(ctx, nodeID) + heartbeatResult, err := c.Heartbeat.Heartbeat(ctx, payload) + if err != nil { + return false, err + } + c.AckObservabilityWindows(ackWindows) + if heartbeatResult == nil { + heartbeatResult = &protocol.HeartbeatResult{} + } + mode := "periodic" + if startup { + mode = "startup" + } + slog.Debug("agent heartbeat succeeded", "mode", mode, "node_id", nodeID) + + var changed bool + if settings != nil { + changed = settings.Apply(heartbeatResult.AgentSettings) + } + c.ApplyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups) + if startup { + if err = c.Sync.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil { + c.recordSyncError(err) + slog.Error("agent startup sync failed", "error", err) + } else { + slog.Debug("agent startup sync completed") + } + } else if err = c.Sync.SyncOnce(ctx, heartbeatResult.ActiveConfig); err != nil { + c.recordSyncError(err) + slog.Error("agent sync failed", "error", err) + } + if settings != nil { + settings.RestartOpenrestyIfNeeded(ctx) + } + edgeheartbeat.TryAutoUpdate(ctx, c.Updater, agentSettingsToAutoUpdate(heartbeatResult.AgentSettings), "agent") + return changed, nil +} + +// NodePayload builds and returns the full NodePayload to be sent in a heartbeat request. +func (c *Cycle) NodePayload(ctx context.Context, nodeID string) protocol.NodePayload { + snapshot, _ := c.StateStore.Load() + openrestyStatus := strings.TrimSpace(snapshot.OpenrestyStatus) + if openrestyStatus == "" { + openrestyStatus = protocol.OpenrestyStatusUnknown + } + profile := observability.BuildProfile(c.Config, c.StateStore) + edgeSnapshot := observability.CollectEdgeHealth(ctx, c.Config) + accessLogs := observability.CollectAccessLogs(c.Config, c.StateStore) + metricSnapshot := observability.BuildSnapshot(c.Config, c.StateStore) + edgeHealth := observability.BuildEdgeHealth(edgeSnapshot, openrestyStatus, snapshot.OpenrestyMessage) + healthEvents := observability.BuildHealthEvents(snapshot) + + ip := c.Config.NodeIP + if !c.Config.NodeIPConfigured { + ip = nodeip.DetectWithContext(ctx) + } + + payload := protocol.NodePayload{ + SchemaVersion: 2, + NodeID: nodeID, + Name: c.Config.NodeName, + IP: ip, + Version: c.Config.Version, + ExtVersion: c.Config.ExtVersion, + CurrentVersion: snapshot.CurrentVersion, + LastError: snapshot.LastError, + OpenrestyStatus: openrestyStatus, + OpenrestyMessage: snapshot.OpenrestyMessage, + Profile: profile, + HostMetrics: metricSnapshot, + EdgeHealth: edgeHealth, + AccessLogs: accessLogs, + HealthEvents: healthEvents, + } + if c.Sync != nil { + checksums, err := c.Sync.WAFIPGroupChecksums() + if err != nil { + slog.Debug("load local waf ip group checksums failed", "error", err) + } else if len(checksums) > 0 { + payload.WAFIPGroupChecksums = checksums + } + } + return payload +} + +// PrepareHeartbeatPayload constructs the heartbeat payload with buffered observability records and returns the window timestamps to acknowledge. +func (c *Cycle) PrepareHeartbeatPayload(ctx context.Context, nodeID string) (protocol.NodePayload, []int64) { + payload := c.NodePayload(ctx, nodeID) + if c.ObservabilityBuffer == nil || (payload.HostMetrics == nil && payload.EdgeHealth == nil && len(payload.AccessLogs) == 0) { + return payload, nil + } + now := time.Now().UTC() + retainAfterUnix := now.Add(-time.Duration(c.Config.ObservabilityReplayMinutes) * time.Minute).Unix() + windowStartedAtUnix := state.ObservabilityWindowStartedAt(payload.HostMetrics, payload.EdgeHealth) + if windowStartedAtUnix <= 0 { + return payload, nil + } + + record := state.ObservabilityBufferRecord{ + WindowStartedAtUnix: windowStartedAtUnix, + HostMetrics: payload.HostMetrics, + EdgeHealth: payload.EdgeHealth, + AccessLogs: payload.AccessLogs, + QueuedAtUnix: now.Unix(), + } + if err := c.ObservabilityBuffer.Upsert(record, retainAfterUnix); err != nil { + slog.Error("upsert observability buffer failed", "error", err) + return payload, nil + } + + records, err := c.ObservabilityBuffer.Replayable(windowStartedAtUnix, retainAfterUnix) + if err != nil { + slog.Error("load replayable observability buffer failed", "error", err) + return payload, []int64{windowStartedAtUnix} + } + + ackWindows := make([]int64, 0, len(records)+1) + buffered := make([]protocol.BufferedObservabilityRecord, 0, len(records)) + for _, item := range records { + if item.WindowStartedAtUnix <= 0 { + continue + } + buffered = append(buffered, protocol.BufferedObservabilityRecord{ + CapturedAtUnix: item.WindowStartedAtUnix, + HostMetrics: item.HostMetrics, + EdgeHealth: item.EdgeHealth, + AccessLogs: item.AccessLogs, + }) + ackWindows = append(ackWindows, item.WindowStartedAtUnix) + } + payload.Buffered = buffered + ackWindows = append(ackWindows, windowStartedAtUnix) + return payload, ackWindows +} + +// AckObservabilityWindows acknowledges the given observability window timestamps in the buffer store. +func (c *Cycle) AckObservabilityWindows(windowStartedAtUnix []int64) { + if c.ObservabilityBuffer == nil || len(windowStartedAtUnix) == 0 { + return + } + retainAfterUnix := time.Now().UTC().Add(-time.Duration(c.Config.ObservabilityReplayMinutes) * time.Minute).Unix() + if err := c.ObservabilityBuffer.Ack(windowStartedAtUnix, retainAfterUnix); err != nil { + slog.Error("ack observability buffer failed", "error", err) + } +} + +// ApplyWAFIPGroups applies the WAF IP groups received from the server via the SyncService. +func (c *Cycle) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) { + if len(groups) == 0 || c.Sync == nil { + return + } + if err := c.Sync.ApplyWAFIPGroups(ctx, groups); err != nil { + c.recordSyncError(err) + slog.Error("agent apply waf ip groups failed", "error", err) + } +} + +func (c *Cycle) recordSyncError(err error) { + if c.RecordSyncError != nil { + c.RecordSyncError(err) + } +} + +// AgentSettingsToAutoUpdate converts AgentSettings to an AutoUpdateSettings value used by the edge heartbeat updater. +func AgentSettingsToAutoUpdate(settings *protocol.AgentSettings) *edgeheartbeat.AutoUpdateSettings { + if settings == nil { + return nil + } + return &edgeheartbeat.AutoUpdateSettings{ + AutoUpdate: settings.AutoUpdate, + UpdateNow: settings.UpdateNow, + UpdateRepo: settings.UpdateRepo, + UpdateChannel: settings.UpdateChannel, + UpdateTag: settings.UpdateTag, + } +} + +func agentSettingsToAutoUpdate(settings *protocol.AgentSettings) *edgeheartbeat.AutoUpdateSettings { + return AgentSettingsToAutoUpdate(settings) +} diff --git a/backend/openflare/plugins/agent/heartbeat/service.go b/backend/openflare/plugins/agent/heartbeat/service.go new file mode 100644 index 00000000..b8065c15 --- /dev/null +++ b/backend/openflare/plugins/agent/heartbeat/service.go @@ -0,0 +1,49 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package heartbeat + +import ( + "context" + + "Wavelet/openflare/plugins/agent/protocol" +) + +// RemoteClient is the interface that abstracts the remote API calls performed by Service. +type RemoteClient interface { + RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) + Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) + SetToken(token string) +} + +// API abstracts registration and heartbeat operations used by Cycle. +type API interface { + Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) + Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) + SetToken(token string) +} + +// Service wraps a RemoteClient to expose agent registration and heartbeat operations. +type Service struct { + client RemoteClient +} + +// New creates a new Service backed by the given RemoteClient. +func New(client RemoteClient) *Service { + return &Service{client: client} +} + +// Register sends a node registration request to the server. +func (s *Service) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) { + return s.client.RegisterNode(ctx, payload) +} + +// Heartbeat sends a heartbeat to the server using the service client. +func (s *Service) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) { + return s.client.Heartbeat(ctx, payload) +} + +// SetToken sets the authentication token for the service client. +func (s *Service) SetToken(token string) { + s.client.SetToken(token) +} diff --git a/backend/openflare/plugins/agent/httpclient/client.go b/backend/openflare/plugins/agent/httpclient/client.go new file mode 100644 index 00000000..467dafde --- /dev/null +++ b/backend/openflare/plugins/agent/httpclient/client.go @@ -0,0 +1,237 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package httpclient provides an authenticated HTTP client for the agent. +package httpclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "Wavelet/openflare/plugins/agent/protocol" + edgehttp "Wavelet/openflare/share/edge/httpclient" +) + +const pagesControlResponseMaxBytes = int64(64 * 1024) + +// Client is a HTTP client used by the agent to communicate with the control plane server. +type Client struct { + base *edgehttp.Client +} + +// New creates a new Client instance with the specified base URL, token, and timeout. +func New(baseURL string, token string, timeout time.Duration) *Client { + return &Client{ + base: edgehttp.New(baseURL, token, timeout, "X-Agent-Token"), + } +} + +// RegisterNode registers the agent node with the control plane server. +func (c *Client) RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) { + resp := protocol.APIResponse[protocol.RegisterNodeResponse]{} + if err := c.base.PostJSON(ctx, "/api/v1/agent/nodes/register", payload, &resp); err != nil { + return nil, err + } + if err := edgehttp.APIError(resp.ErrorMsg); err != nil { + return nil, err + } + return &resp.Data, nil +} + +// Heartbeat sends a heartbeat payload to the control plane and returns the response result. +func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.HeartbeatResult, error) { + resp := protocol.APIResponse[protocol.HeartbeatData]{} + if err := c.base.PostJSON(ctx, "/api/v1/agent/nodes/heartbeat", payload, &resp); err != nil { + return nil, err + } + if err := edgehttp.APIError(resp.ErrorMsg); err != nil { + return nil, err + } + return &protocol.HeartbeatResult{ + AgentSettings: resp.Data.AgentSettings, + ActiveConfig: resp.Data.ActiveConfig, + WAFIPGroups: resp.Data.WAFIPGroups, + }, nil +} + +// GetActiveConfig retrieves the current active configuration from the control plane server. +func (c *Client) GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error) { + resp := protocol.APIResponse[protocol.ActiveConfigResponse]{} + if err := c.base.GetJSON(ctx, "/api/v1/agent/config-versions/active", &resp); err != nil { + return nil, err + } + if err := edgehttp.APIError(resp.ErrorMsg); err != nil { + return nil, err + } + return &resp.Data, nil +} + +// ReportApplyLog reports the configuration application logs back to the control plane. +func (c *Client) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error { + resp := protocol.APIResponse[json.RawMessage]{} + if err := c.base.PostJSON(ctx, "/api/v1/agent/apply-logs", payload, &resp); err != nil { + return err + } + return edgehttp.APIError(resp.ErrorMsg) +} + +// SyncWAFIPGroups synchronizes WAF IP groups with the control plane server. +func (c *Client) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error) { + resp := protocol.APIResponse[protocol.WAFIPGroupSyncResponse]{} + if err := c.base.PostJSON(ctx, "/api/v1/agent/waf/ip-groups/sync", payload, &resp); err != nil { + return nil, err + } + if err := edgehttp.APIError(resp.ErrorMsg); err != nil { + return nil, err + } + return &resp.Data, nil +} + +// GetPagesDeploymentHash returns the upload SHA-256 hash for the given Pages deployment ID. +func (c *Client) GetPagesDeploymentHash(ctx context.Context, deploymentID uint) (string, error) { + resp := protocol.APIResponse[protocol.PagesDeploymentHashResponse]{} + if err := c.base.GetJSON(ctx, fmt.Sprintf("/api/v1/agent/pages/deployments/%d/hash", deploymentID), &resp); err != nil { + return "", err + } + if err := edgehttp.APIError(resp.ErrorMsg); err != nil { + return "", err + } + return resp.Data.Hash, nil +} + +// DownloadPagesDeploymentPackage streams the deployment package into dst while +// enforcing maxBytes against both advertised and actual response sizes. +func (c *Client) DownloadPagesDeploymentPackage( + ctx context.Context, + deploymentID uint, + dst io.Writer, + maxBytes int64, +) (int64, error) { + return c.downloadPagesPackage( + ctx, + fmt.Sprintf("/api/v1/agent/pages/deployments/%d/package", deploymentID), + dst, + maxBytes, + ) +} + +// GetPagesProjectLatestHash returns the active deployment package hash for a Pages project. +func (c *Client) GetPagesProjectLatestHash(ctx context.Context, projectID uint) (*protocol.PagesProjectLatestHashResponse, error) { + res, err := c.base.DoRaw( + ctx, + http.MethodGet, + fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/hash", projectID), + nil, + ) + if err != nil { + return nil, err + } + defer func() { _ = res.Body.Close() }() + body, err := readPagesControlResponse(res, pagesControlResponseMaxBytes) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, edgehttp.ReadBodyError(body, res.Status) + } + resp := protocol.APIResponse[protocol.PagesProjectLatestHashResponse]{} + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + if err := edgehttp.APIError(resp.ErrorMsg); err != nil { + return nil, err + } + return &resp.Data, nil +} + +// DownloadPagesProjectLatestPackage streams the active deployment package into +// dst while enforcing maxBytes against both advertised and actual sizes. +func (c *Client) DownloadPagesProjectLatestPackage( + ctx context.Context, + projectID uint, + dst io.Writer, + maxBytes int64, +) (int64, error) { + return c.downloadPagesPackage( + ctx, + fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/package", projectID), + dst, + maxBytes, + ) +} + +func (c *Client) downloadPagesPackage( + ctx context.Context, + path string, + dst io.Writer, + maxBytes int64, +) (int64, error) { + if dst == nil { + return 0, errors.New("pages package destination is required") + } + if maxBytes <= 0 { + return 0, errors.New("pages package byte limit must be positive") + } + res, err := c.base.DoRaw(ctx, http.MethodGet, path, nil) + if err != nil { + return 0, err + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { + body, readErr := readPagesControlResponse(res, pagesControlResponseMaxBytes) + if readErr != nil { + return 0, readErr + } + return 0, edgehttp.ReadBodyError(body, res.Status) + } + return copyPagesPackageResponse(dst, res, maxBytes) +} + +func readPagesControlResponse(res *http.Response, maxBytes int64) ([]byte, error) { + if res.ContentLength > maxBytes { + return nil, fmt.Errorf( + "pages control response Content-Length %d exceeds limit %d", + res.ContentLength, + maxBytes, + ) + } + limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1} + body, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("read pages control response: %w", err) + } + if int64(len(body)) > maxBytes { + return nil, fmt.Errorf("pages control response body exceeds limit %d", maxBytes) + } + return body, nil +} + +func copyPagesPackageResponse(dst io.Writer, res *http.Response, maxBytes int64) (int64, error) { + if res.ContentLength > maxBytes { + return 0, fmt.Errorf( + "pages package Content-Length %d exceeds limit %d", + res.ContentLength, + maxBytes, + ) + } + + limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1} + written, err := io.Copy(dst, limited) + if err != nil { + return written, fmt.Errorf("stream pages package: %w", err) + } + if written > maxBytes { + return written, fmt.Errorf("pages package body exceeds limit %d", maxBytes) + } + return written, nil +} + +// SetToken updates the authentication token used for API requests. +func (c *Client) SetToken(token string) { + c.base.SetToken(token) +} diff --git a/backend/openflare/plugins/agent/httpclient/client_test.go b/backend/openflare/plugins/agent/httpclient/client_test.go new file mode 100644 index 00000000..613c2dda --- /dev/null +++ b/backend/openflare/plugins/agent/httpclient/client_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package httpclient + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestDownloadPagesProjectLatestPackageRejectsChunkedBodyOverLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + _, _ = io.WriteString(w, "123456") + })) + defer server.Close() + + client := New(server.URL, "test-token", time.Second) + var dst bytes.Buffer + written, err := client.DownloadPagesProjectLatestPackage( + context.Background(), + 7, + &dst, + 4, + ) + if err == nil || !strings.Contains(err.Error(), "body exceeds limit") { + t.Fatalf("DownloadPagesProjectLatestPackage(chunked, limit=4) error = %v, want body limit error", err) + } + if written != 5 { + t.Errorf("DownloadPagesProjectLatestPackage(chunked, limit=4) written = %d, want 5", written) + } +} + +func TestCopyPagesPackageResponseRejectsAdvertisedContentLengthBeforeWrite(t *testing.T) { + response := &http.Response{ + Body: io.NopCloser(strings.NewReader("123456")), + ContentLength: 6, + } + var dst bytes.Buffer + written, err := copyPagesPackageResponse(&dst, response, 4) + if err == nil || !strings.Contains(err.Error(), "Content-Length") { + t.Fatalf("copyPagesPackageResponse(Content-Length=6, limit=4) error = %v, want Content-Length limit error", err) + } + if written != 0 || dst.Len() != 0 { + t.Errorf("copyPagesPackageResponse(Content-Length=6, limit=4) wrote (%d, %d buffered), want no writes", written, dst.Len()) + } +} + +func TestCopyPagesPackageResponseRejectsForgedSmallContentLength(t *testing.T) { + response := &http.Response{ + Body: io.NopCloser(strings.NewReader("123456")), + ContentLength: 2, + } + var dst bytes.Buffer + written, err := copyPagesPackageResponse(&dst, response, 4) + if err == nil || !strings.Contains(err.Error(), "body exceeds limit") { + t.Fatalf("copyPagesPackageResponse(forged Content-Length=2, limit=4) error = %v, want body limit error", err) + } + if written != 5 { + t.Errorf("copyPagesPackageResponse(forged Content-Length=2, limit=4) written = %d, want 5", written) + } +} + +func TestDownloadPagesProjectLatestPackageBoundsChunkedErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + _, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1))) + })) + defer server.Close() + + client := New(server.URL, "test-token", time.Second) + var dst bytes.Buffer + _, err := client.DownloadPagesProjectLatestPackage(context.Background(), 7, &dst, 1024) + if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") { + t.Fatalf("DownloadPagesProjectLatestPackage(large chunked 400) error = %v, want bounded response error", err) + } + if dst.Len() != 0 { + t.Errorf("DownloadPagesProjectLatestPackage(large chunked 400) wrote %d package bytes, want 0", dst.Len()) + } +} + +func TestGetPagesProjectLatestHashBoundsChunkedMetadataResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + _, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1))) + })) + defer server.Close() + + client := New(server.URL, "test-token", time.Second) + _, err := client.GetPagesProjectLatestHash(context.Background(), 7) + if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") { + t.Fatalf("GetPagesProjectLatestHash(large chunked metadata) error = %v, want bounded response error", err) + } +} diff --git a/backend/openflare/plugins/agent/logging/logger.go b/backend/openflare/plugins/agent/logging/logger.go new file mode 100644 index 00000000..1689a257 --- /dev/null +++ b/backend/openflare/plugins/agent/logging/logger.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package logging configures structured logging for the agent process. +package logging + +import edgelogging "Wavelet/openflare/share/edge/logging" + +// Setup initialises structured logging for the agent process. +func Setup() { + edgelogging.Setup(edgelogging.Options{AddSource: true}) +} diff --git a/backend/openflare/plugins/agent/nginx/manager.go b/backend/openflare/plugins/agent/nginx/manager.go new file mode 100644 index 00000000..bb803d62 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/manager.go @@ -0,0 +1,1661 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package nginx manages OpenResty configuration, runtime, and supporting assets. +package nginx + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "Wavelet/openflare/share/ofutil" + sharedprotocol "Wavelet/openflare/share/protocol" + openrestyrender "Wavelet/openflare/share/render/openresty" + + "Wavelet/openflare/plugins/agent/protocol" + "Wavelet/openflare/plugins/agent/runtimeuser" +) + +// RuntimeConfigDirPlaceholder is substituted into generated configs at apply time. +const RuntimeConfigDirPlaceholder = "__OPENFLARE_RUNTIME_CONFIG_DIR__" + +// CountryMMDBPathPlaceholder is substituted with the configured Country database path. +const CountryMMDBPathPlaceholder = "__OPENFLARE_COUNTRY_MMDB_PATH__" + +// CityMMDBPathPlaceholder is substituted with the configured City database path. +const CityMMDBPathPlaceholder = "__OPENFLARE_CITY_MMDB_PATH__" + +// WAFIPGroupsMaxSnapshotBytesPlaceholder is replaced with the shared protocol aggregate snapshot limit. +const WAFIPGroupsMaxSnapshotBytesPlaceholder = "__OPENFLARE_WAF_IP_GROUPS_MAX_SNAPSHOT_BYTES__" + +// ResolverDirectivePlaceholder is substituted into generated configs at apply time. +const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__" + +// WAFIPGroupsConfigFileName is the runtime filename for synced WAF IP group data. +const WAFIPGroupsConfigFileName = "waf_ip_groups.json" + +// WAFIPGroupsChecksumFileName is atomically published after the IP group JSON snapshot. +const WAFIPGroupsChecksumFileName = WAFIPGroupsConfigFileName + ".checksum" +const powConfigFileName = "pow_config.json" + +const ( + nginxConfigFilePerm = 0o644 + nginxPrivateKeyFilePerm = 0o600 + nginxDirPerm = 0o755 + stubStatusCheckTimeout = 1500 * time.Millisecond + nginxVersionSubmatchCount = 2 + resolverAddressCapacity = 2 + workerInitSubmatchCount = 2 +) + +// Executor controls OpenResty validation, reload, health, and lifecycle operations. +type Executor interface { + Test(ctx context.Context) error + Reload(ctx context.Context) error + EnsureRuntime(ctx context.Context, recreate bool) error + CheckHealth(ctx context.Context) error + Restart(ctx context.Context) error +} + +// CommandRunner executes external commands on behalf of an Executor. +type CommandRunner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// OSCommandRunner runs commands using the host operating system. +type OSCommandRunner struct{} + +// Run executes the named command and returns its combined output. +func (r *OSCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + slog.Debug("OSCommandRunner starting command", "name", name, "args", args) + tmpFile, err := os.CreateTemp("", "openflare-cmd-*") + if err != nil { + slog.Error("OSCommandRunner failed to create temp file, falling back to CombinedOutput", "error", err) + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // command name and args come from trusted OpenResty management paths + output, outErr := cmd.CombinedOutput() + slog.Debug("OSCommandRunner finished CombinedOutput", "name", name, "error", outErr) + return output, outErr + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // command name and args come from trusted OpenResty management paths + cmd.Stdout = tmpFile + cmd.Stderr = tmpFile + + slog.Debug("OSCommandRunner executing cmd.Run()", "name", name) + runErr := cmd.Run() + slog.Debug("OSCommandRunner cmd.Run() returned", "name", name, "error", runErr) + _ = tmpFile.Close() + + output, _ := os.ReadFile(tmpFile.Name()) + slog.Debug("OSCommandRunner command complete", "name", name, "output_len", len(output)) + return output, runErr +} + +// PathExecutor runs OpenResty using a configured binary and config path. +type PathExecutor struct { + Path string + ConfigPath string + Runner CommandRunner +} + +// Test validates the current OpenResty configuration. +func (e *PathExecutor) Test(ctx context.Context) error { + slog.Debug("running openresty test with binary", "path", e.Path, "config", e.ConfigPath) + output, err := e.Runner.Run(ctx, e.Path, "-t", "-c", e.ConfigPath) + if err != nil { + return fmt.Errorf("openresty -t failed: %w: %s", err, string(output)) + } + slog.Debug("openresty test succeeded with binary", "path", e.Path) + return nil +} + +// Reload reloads OpenResty or starts it when no runtime process is running. +func (e *PathExecutor) Reload(ctx context.Context) error { + slog.Debug("running openresty reload with binary", "path", e.Path, "config", e.ConfigPath) + output, err := e.Runner.Run(ctx, e.Path, "-s", "reload", "-c", e.ConfigPath) + if err != nil { + if isOpenrestyNotRunningError(string(output)) { + slog.Warn("openresty reload reported runtime is not running, starting binary", "path", e.Path) + startOutput, startErr := e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath) + if startErr != nil { + return fmt.Errorf("openresty reload failed: %w: %s; start failed: %w: %s", err, string(output), startErr, string(startOutput)) + } + return nil + } + return fmt.Errorf("openresty reload failed: %w: %s", err, string(output)) + } + slog.Debug("openresty reload succeeded with binary", "path", e.Path) + return nil +} + +// EnsureRuntime validates configuration and reloads the OpenResty runtime. +func (e *PathExecutor) EnsureRuntime(ctx context.Context, _ bool) error { + if err := e.Test(ctx); err != nil { + return err + } + return e.Reload(ctx) +} + +// CheckHealth reports whether the OpenResty configuration is valid. +func (e *PathExecutor) CheckHealth(ctx context.Context) error { + return e.Test(ctx) +} + +// Restart stops and starts the OpenResty runtime process. +func (e *PathExecutor) Restart(ctx context.Context) error { + slog.Info("restarting openresty with binary", "path", e.Path, "config", e.ConfigPath) + output, err := e.Runner.Run(ctx, e.Path, "-s", "quit", "-c", e.ConfigPath) + if err != nil { + text := string(output) + if !isIgnorableOpenrestyStopError(text) { + return fmt.Errorf("openresty stop failed: %w: %s", err, text) + } + } + output, err = e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath) + if err != nil { + return fmt.Errorf("openresty start failed: %w: %s", err, string(output)) + } + slog.Info("openresty restart succeeded with binary", "path", e.Path) + return nil +} + +// Manager applies OpenResty configuration and manages runtime assets. +type Manager struct { + MainConfigPath string + RouteConfigPath string + AccessLogPath string + CertDir string + NginxCertDir string + LuaDir string + NginxLuaDir string + RuntimeConfigDir string + MMDBPath string + CityMMDBPath string + PagesDir string + OpenrestyObservabilityListen string + OpenrestyObservabilityPort int + OpenrestyResolverDirective string + Executor Executor + atomicFileWriter func(path string, data []byte, perm os.FileMode) error + wafIPGroupsMu sync.Mutex +} + +// ApplyStatus reports the outcome of an OpenResty configuration apply. +type ApplyStatus string + +// Apply outcome status values. +const ( + // ApplyStatusSuccess indicates the configuration was applied successfully. + ApplyStatusSuccess ApplyStatus = "success" + ApplyStatusWarning ApplyStatus = "warning" + ApplyStatusFatal ApplyStatus = "fatal" +) + +const safeDefaultFallbackMainConfig = `# This file is generated by OpenFlare safe default fallback. +user ` + runtimeuser.Name + `; +worker_processes auto; +pid __OPENFLARE_PID_PATH__; + +events { + worker_connections 1024; +} + +http { + default_type text/plain; + server_tokens off; + client_body_temp_path __OPENFLARE_NGINX_CACHE_DIR__/client_temp; + proxy_temp_path __OPENFLARE_NGINX_CACHE_DIR__/proxy_temp; + fastcgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/fastcgi_temp; + uwsgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/uwsgi_temp; + scgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/scgi_temp; + + server { + listen 80 default_server; + server_name _; + return 503 "OpenFlare: No Valid Configuration\n"; + } +%s +} +` + +const safeDefaultFallbackObservabilityServerBlock = ` + server { + listen %s; + server_name openflare-observability; + access_log off; + + location = /openflare/stub_status { + stub_status; + } + } +` + +// ApplyOutcome contains the status and message from a configuration apply. +type ApplyOutcome struct { + Status ApplyStatus + Message string +} + +type wafIPGroupsRuntimeConfig struct { + Groups map[string]protocol.WAFIPGroup `json:"groups"` +} + +// Apply writes, validates, and activates new OpenResty configuration files. +func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome { + slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles)) + backup, err := m.backup() + if err != nil { + return fatalApplyOutcome(fmt.Errorf("backup openresty config failed: %w", err)) + } + if err = m.writeTargetFiles(mainConfig, routeConfig, supportFiles); err != nil { + return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("write openresty config failed: %w", err)) + } + if err = m.activateConfig(ctx); err != nil { + return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("activate openresty runtime failed: %w", err)) + } + slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath) + return ApplyOutcome{Status: ApplyStatusSuccess} +} + +func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error { + if err := m.EnsureLuaAssets(); err != nil { + return err + } + if err := m.writeCertFiles(supportFiles); err != nil { + return err + } + if err := m.writePowConfig(supportFiles); err != nil { + return err + } + if err := m.writeWAFConfig(supportFiles); err != nil { + return err + } + if err := m.writeSourceConfig(supportFiles); err != nil { + return err + } + if err := m.ensureMimeTypes(); err != nil { + return err + } + if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") { + slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured") + } + renderedMainConfig := m.renderMainConfig(mainConfig) + if err := os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), nginxConfigFilePerm); err != nil { + return err + } + renderedRouteConfig := m.renderRouteConfig(routeConfig) + if err := os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), nginxConfigFilePerm); err != nil { + return err + } + return m.ensureOpenRestyWorkerReadAccess() +} + +// ensureOpenRestyWorkerReadAccess assigns runtime ownership and normalized modes +// on agent-managed paths so the agent and OpenResty workers share access. +func (m *Manager) ensureOpenRestyWorkerReadAccess() error { + targets := []string{ + m.RuntimeConfigDir, + m.LuaDir, + m.PagesDir, + filepath.Dir(m.MainConfigPath), + filepath.Dir(m.RouteConfigPath), + } + if m.AccessLogPath != "" { + targets = append(targets, filepath.Dir(m.AccessLogPath)) + } + if pidPath := m.pidRuntimePath(); pidPath != "" { + targets = append(targets, filepath.Dir(pidPath)) + } + if proxyCacheDir := m.proxyCacheRuntimeDir(); proxyCacheDir != "" { + targets = append(targets, proxyCacheDir) + } + seen := make(map[string]struct{}, len(targets)) + for _, target := range targets { + cleaned := filepath.Clean(strings.TrimSpace(target)) + if cleaned == "" || cleaned == "." { + continue + } + if _, ok := seen[cleaned]; ok { + continue + } + seen[cleaned] = struct{}{} + if err := runtimeuser.EnsurePathOwnership(cleaned, nginxDirPerm, nginxConfigFilePerm); err != nil { + return err + } + } + return nil +} + +func (m *Manager) activateConfig(ctx context.Context) error { + if m.Executor == nil { + return errors.New("executor 未配置") + } + if err := m.Executor.Test(ctx); err != nil { + return err + } + return m.Executor.Reload(ctx) +} + +func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupState, applyErr error) ApplyOutcome { + slog.Warn("openresty apply failed, restoring previous config", "error", applyErr) + if err := m.restore(backup); err != nil { + return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %w: %w", applyErr, err)) + } + if err := m.activateConfig(ctx); err != nil { + if backup != nil && backup.MainExisted { + return fatalApplyOutcome(fmt.Errorf("apply failed: %w; rollback recovery failed: %w", applyErr, err)) + } + if fallbackErr := m.EnsureSafeFallbackRuntime(ctx, fmt.Sprintf("apply failed: %v; rollback recovery failed: %v", applyErr, err)); fallbackErr != nil { + return fatalApplyOutcome(fmt.Errorf("apply failed: %w; rollback recovery failed: %w; fallback recovery failed: %w", applyErr, err, fallbackErr)) + } + message := fmt.Sprintf("apply failed, but fallback runtime started: %v; rollback recovery failed: %v", applyErr, err) + slog.Warn("openresty apply recovered with safe default fallback", "message", message) + return ApplyOutcome{ + Status: ApplyStatusWarning, + Message: message, + } + } + message := fmt.Sprintf("apply failed, rolled back to previous config: %v", applyErr) + slog.Warn("openresty apply rolled back successfully", "message", message) + return ApplyOutcome{ + Status: ApplyStatusWarning, + Message: message, + } +} + +func fatalApplyOutcome(err error) ApplyOutcome { + if err == nil { + return ApplyOutcome{Status: ApplyStatusFatal} + } + return ApplyOutcome{ + Status: ApplyStatusFatal, + Message: strings.TrimSpace(err.Error()), + } +} + +// EnsureLuaAssets synchronizes managed Lua and static assets to the runtime directory. +func (m *Manager) EnsureLuaAssets() error { + if strings.TrimSpace(m.LuaDir) == "" { + return nil + } + allSupportFiles := append(ManagedObservabilityLuaFiles(), m.managedPowLuaFiles()...) + allSupportFiles = append(allSupportFiles, m.managedWAFLuaFiles()...) + powStaticFiles, err := ManagedPowStaticFiles() + if err != nil { + return fmt.Errorf("load pow static files: %w", err) + } + allSupportFiles = append(allSupportFiles, powStaticFiles...) + allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...) + files := make([]managedFile, 0, len(allSupportFiles)) + for _, file := range allSupportFiles { + targetPath, err := luaFileTargetPath(m.LuaDir, file.Path) + if err != nil { + return err + } + relativePath, err := filepath.Rel(m.LuaDir, targetPath) + if err != nil { + return err + } + files = append(files, managedFile{ + Path: filepath.ToSlash(relativePath), + Content: []byte(file.Content), + Mode: nginxConfigFilePerm, + }) + } + return syncManagedFiles(m.LuaDir, files) +} + +// EnsureRuntime validates and reloads the current OpenResty runtime configuration. +func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error { + if m.Executor == nil { + return errors.New("executor 未配置") + } + slog.Info("openresty ensure runtime requested", "recreate", recreate) + return m.Executor.EnsureRuntime(ctx, recreate) +} + +// EnsureSafeFallbackRuntime starts a minimal safe default OpenResty runtime. +func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string) error { + if m.Executor == nil { + return errors.New("executor 未配置") + } + trimmedReason := strings.TrimSpace(reason) + if trimmedReason == "" { + trimmedReason = "no valid local openresty config is available" + } + slog.Warn("starting openresty safe default fallback runtime", "reason", trimmedReason) + if err := m.writeSafeDefaultFallbackFiles(); err != nil { + return fmt.Errorf("write safe default fallback config failed: %w", err) + } + if err := m.activateConfig(ctx); err != nil { + return fmt.Errorf("activate safe default fallback runtime failed: %w", err) + } + slog.Warn("openresty safe default fallback runtime started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath) + return nil +} + +// CheckHealth verifies that OpenResty configuration and health endpoints are available. +func (m *Manager) CheckHealth(ctx context.Context) error { + if m.Executor == nil { + return errors.New("executor 未配置") + } + if m.MainConfigPath != "" { + if _, err := os.Stat(m.MainConfigPath); os.IsNotExist(err) { + return errors.New("openresty config not exists: waiting for initial sync") + } + } + if m.OpenrestyObservabilityPort <= 0 { + return m.Executor.CheckHealth(ctx) + } + return m.checkStubStatus(ctx) +} + +// Restart restarts the OpenResty runtime process. +func (m *Manager) Restart(ctx context.Context) error { + if m.Executor == nil { + return errors.New("executor 未配置") + } + slog.Info("openresty restart requested") + return m.Executor.Restart(ctx) +} + +// CurrentChecksum returns a stable checksum for the active OpenResty configuration bundle. +func (m *Manager) CurrentChecksum() (string, error) { + if m.RouteConfigPath == "" { + return "", errors.New("route config path 不能为空") + } + if m.MainConfigPath == "" { + return "", errors.New("main config path 不能为空") + } + mainData, err := os.ReadFile(m.MainConfigPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + data, err := os.ReadFile(m.RouteConfigPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + normalizedMain := string(mainData) + if includePath := m.routeConfigIncludePath(); includePath != "" { + normalizedMain = strings.ReplaceAll(normalizedMain, includePath, openrestyrender.RouteConfigPlaceholder) + } + if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { + normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, openrestyrender.AccessLogPlaceholder) + errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log") + normalizedMain = strings.ReplaceAll(normalizedMain, filepath.ToSlash(errorLogPath), openrestyrender.ErrorLogPlaceholder) + } + if pidPath := m.pidRuntimePath(); pidPath != "" { + normalizedMain = strings.ReplaceAll(normalizedMain, filepath.ToSlash(pidPath), openrestyrender.PIDPathPlaceholder) + } + if luaDir := m.luaRuntimePath(); luaDir != "" { + normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, openrestyrender.LuaDirPlaceholder) + } + if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { + normalizedMain = strings.ReplaceAll(normalizedMain, listen, openrestyrender.ObservabilityListenPlaceholder) + } + if m.OpenrestyObservabilityPort > 0 { + normalizedMain = strings.ReplaceAll(normalizedMain, strconv.Itoa(m.OpenrestyObservabilityPort), openrestyrender.ObservabilityPortPlaceholder) + } + if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" { + normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder) + } + normalizedRoute := string(data) + if m.NginxCertDir != "" { + // Longer error-page path must be restored before the cert-dir prefix rewrite. + errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath)) + normalizedRoute = strings.ReplaceAll(normalizedRoute, errorPagePath, openrestyrender.ErrorPageTmplPlaceholder) + swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw")) + normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder) + normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder) + } + if luaDir := m.luaRuntimePath(); luaDir != "" { + normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder) + normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder) + } + if pagesDir := m.pagesRuntimePath(); pagesDir != "" { + normalizedRoute = strings.ReplaceAll(normalizedRoute, pagesDir, openrestyrender.PagesDirPlaceholder) + } + files, err := m.readManagedSupportFiles() + if err != nil { + return "", err + } + result := bundleChecksum(normalizedMain, normalizedRoute, files) + slog.Debug("openresty current checksum calculated", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "checksum", result, "cert_files", len(files)) + return result, nil +} + +// WAFIPGroupChecksums returns checksums for locally synced WAF IP groups. +func (m *Manager) WAFIPGroupChecksums() (map[string]string, error) { + m.wafIPGroupsMu.Lock() + defer m.wafIPGroupsMu.Unlock() + + config, err := m.readWAFIPGroupsRuntimeConfig() + if err != nil { + return nil, err + } + if err = m.ensureWAFIPGroupsChecksum(); err != nil { + return nil, err + } + result := make(map[string]string, len(config.Groups)) + for id, group := range config.Groups { + if id != strconv.FormatUint(uint64(group.ID), 10) { + continue + } + if strings.TrimSpace(group.Checksum) != "" { + result[id] = strings.TrimSpace(group.Checksum) + } + } + return result, nil +} + +// ReconcileWAFIPGroups atomically replaces the runtime snapshot with exactly the +// authoritative target IDs, retaining local definitions that did not change. +func (m *Manager) ReconcileWAFIPGroups(targetIDs []uint, changed []protocol.WAFIPGroup) error { + if m.RuntimeConfigDir == "" { + return nil + } + m.wafIPGroupsMu.Lock() + defer m.wafIPGroupsMu.Unlock() + + config, err := m.readWAFIPGroupsRuntimeConfig() + if err != nil { + return err + } + target := make(map[string]protocol.WAFIPGroup, len(targetIDs)) + targetSet := make(map[uint]struct{}, len(targetIDs)) + for _, id := range targetIDs { + if id == 0 { + continue + } + targetSet[id] = struct{}{} + key := strconv.FormatUint(uint64(id), 10) + if group, ok := config.Groups[key]; ok && group.ID == id { + target[key] = group + } + } + for _, group := range changed { + if _, ok := targetSet[group.ID]; !ok { + continue + } + target[strconv.FormatUint(uint64(group.ID), 10)] = group + } + for _, id := range targetIDs { + if id == 0 { + continue + } + if _, ok := target[strconv.FormatUint(uint64(id), 10)]; !ok { + return fmt.Errorf("missing referenced WAF IP group %d after synchronization", id) + } + } + return m.publishWAFIPGroups(target) +} + +// UpdateExistingWAFIPGroups applies real-time changes only to groups already +// present in the authoritative local snapshot. +func (m *Manager) UpdateExistingWAFIPGroups(changed []protocol.WAFIPGroup) error { + if m.RuntimeConfigDir == "" || len(changed) == 0 { + return nil + } + m.wafIPGroupsMu.Lock() + defer m.wafIPGroupsMu.Unlock() + + config, err := m.readWAFIPGroupsRuntimeConfig() + if err != nil { + return err + } + updated := false + for _, group := range changed { + key := strconv.FormatUint(uint64(group.ID), 10) + if _, ok := config.Groups[key]; !ok || group.ID == 0 { + continue + } + config.Groups[key] = group + updated = true + } + if !updated { + return nil + } + return m.publishWAFIPGroups(config.Groups) +} + +func (m *Manager) publishWAFIPGroups(groups map[string]protocol.WAFIPGroup) error { + data, err := sharedprotocol.MarshalWAFIPGroupSnapshot(groups) + if err != nil { + return err + } + if len(data) > sharedprotocol.MaxWAFIPGroupSnapshotBytes { + return fmt.Errorf("WAF IP group snapshot size %d exceeds maximum %d bytes", len(data), sharedprotocol.MaxWAFIPGroupSnapshotBytes) + } + if err := os.MkdirAll(m.RuntimeConfigDir, nginxDirPerm); err != nil { + return err + } + path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName) + if err := m.writeAtomicFile(path, data, nginxConfigFilePerm); err != nil { + return fmt.Errorf("write %s: %w", WAFIPGroupsConfigFileName, err) + } + checksumPath := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsChecksumFileName) + if err := m.writeAtomicFile(checksumPath, []byte(checksum(string(data))+"\n"), nginxConfigFilePerm); err != nil { + return fmt.Errorf("write %s: %w", WAFIPGroupsChecksumFileName, err) + } + slog.Info("synced waf ip groups", "path", path, "group_count", len(groups)) + return nil +} + +func (m *Manager) ensureWAFIPGroupsChecksum() error { + if m.RuntimeConfigDir == "" { + return nil + } + jsonPath := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName) + data, err := os.ReadFile(jsonPath) //nolint:gosec // path is under managed RuntimeConfigDir + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + expected := checksum(string(data)) + checksumPath := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsChecksumFileName) + current, err := os.ReadFile(checksumPath) //nolint:gosec // path is under managed RuntimeConfigDir + if err == nil && strings.TrimSpace(string(current)) == expected { + return nil + } + if err != nil && !os.IsNotExist(err) { + return err + } + return m.writeAtomicFile(checksumPath, []byte(expected+"\n"), nginxConfigFilePerm) +} + +func (m *Manager) writeAtomicFile(path string, data []byte, perm os.FileMode) error { + if m.atomicFileWriter != nil { + return m.atomicFileWriter(path, data, perm) + } + return writeAtomicFile(path, data, perm) +} + +func writeAtomicFile(path string, data []byte, perm os.FileMode) (resultErr error) { + tempFile, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tempPath := tempFile.Name() + closed := false + defer func() { + if !closed { + if closeErr := tempFile.Close(); resultErr == nil && closeErr != nil { + resultErr = closeErr + } + } + _ = os.Remove(tempPath) + }() + if err = tempFile.Chmod(perm); err != nil { + return err + } + if _, err = tempFile.Write(data); err != nil { + return err + } + if err = tempFile.Sync(); err != nil { + return err + } + if err = tempFile.Close(); err != nil { + return err + } + closed = true + if err = os.Rename(tempPath, path); err != nil { + return err + } + return nil +} + +func (m *Manager) readWAFIPGroupsRuntimeConfig() (*wafIPGroupsRuntimeConfig, error) { + config := &wafIPGroupsRuntimeConfig{Groups: map[string]protocol.WAFIPGroup{}} + if m.RuntimeConfigDir == "" { + return config, nil + } + path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName) + data, err := os.ReadFile(path) //nolint:gosec // path is under managed RuntimeConfigDir + if err != nil { + if os.IsNotExist(err) { + return config, nil + } + return nil, err + } + if len(data) == 0 { + return config, nil + } + if err := json.Unmarshal(data, config); err != nil { + return nil, err + } + if config.Groups == nil { + config.Groups = map[string]protocol.WAFIPGroup{} + } + return config, nil +} + +// ExecutorOptions configures construction of an OpenResty Executor. +type ExecutorOptions struct { + NginxPath string + MainConfigPath string + RouteConfigPath string + CertDir string + NginxCertDir string + LuaDir string + NginxLuaDir string + OpenrestyObservabilityPort int +} + +// NewExecutor creates an Executor backed by a configured OpenResty binary. +func NewExecutor(options ExecutorOptions) Executor { + runner := &OSCommandRunner{} + return &PathExecutor{ + Path: strings.TrimSpace(options.NginxPath), + ConfigPath: strings.TrimSpace(options.MainConfigPath), + Runner: runner, + } +} + +// DetectVersion returns the OpenResty version reported by the configured binary. +func DetectVersion(ctx context.Context, options ExecutorOptions) string { + version, err := detectVersion(ctx, options, &OSCommandRunner{}) + if err != nil { + slog.Error("detect openresty version failed", "error", err) + return "" + } + slog.Info("detected openresty version", "version", version) + return version +} + +func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandRunner) (string, error) { + if runner == nil { + runner = &OSCommandRunner{} + } + if options.NginxPath != "" { + output, err := runner.Run(ctx, options.NginxPath, "-v") + if err != nil { + return "", fmt.Errorf("run runtime -v failed: %w: %s", err, string(output)) + } + version := parseExtVersion(string(output)) + if version == "" { + return "", errors.New("cannot parse runtime version from binary output") + } + return version, nil + } + return "", errors.New("openresty path is empty") +} + +func parseExtVersion(output string) string { + matches := nginxVersionPattern.FindStringSubmatch(output) + if len(matches) != nginxVersionSubmatchCount { + return "" + } + return matches[1] +} + +var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/(\S+)`) + +func isIgnorableOpenrestyStopError(output string) bool { + text := strings.ToLower(strings.TrimSpace(output)) + if text == "" { + return false + } + return strings.Contains(text, "invalid pid") || strings.Contains(text, "no such process") +} + +func isOpenrestyNotRunningError(output string) bool { + text := strings.ToLower(strings.TrimSpace(output)) + if text == "" { + return false + } + return strings.Contains(text, "invalid pid") || + strings.Contains(text, "no such process") || + strings.Contains(text, "open()") && strings.Contains(text, "nginx.pid") && strings.Contains(text, "failed") +} + +type backupState struct { + MainExisted bool + MainData []byte + RouteExisted bool + RouteData []byte + Files []protocol.SupportFile + PowConfig *protocol.SupportFile + WAFConfig *protocol.SupportFile + SourceConfig *protocol.SupportFile +} + +type managedFile struct { + Path string + Content []byte + Mode fs.FileMode +} + +func (m *Manager) backup() (*backupState, error) { + if m.MainConfigPath == "" { + return nil, errors.New("main config path 不能为空") + } + if m.RouteConfigPath == "" { + return nil, errors.New("route config path 不能为空") + } + if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), nginxDirPerm); err != nil { + return nil, err + } + if m.AccessLogPath != "" { + if err := os.MkdirAll(filepath.Dir(m.AccessLogPath), nginxDirPerm); err != nil { + return nil, err + } + } + if m.CertDir != "" { + if err := os.MkdirAll(m.CertDir, nginxDirPerm); err != nil { + return nil, err + } + } + if m.RuntimeConfigDir != "" { + if err := os.MkdirAll(m.RuntimeConfigDir, nginxDirPerm); err != nil { + return nil, err + } + } + state := &backupState{} + mainData, err := os.ReadFile(m.MainConfigPath) + if err == nil { + state.MainExisted = true + state.MainData = mainData + } else if !os.IsNotExist(err) { + return nil, err + } + data, err := os.ReadFile(m.RouteConfigPath) + if err == nil { + state.RouteExisted = true + state.RouteData = data + } else if !os.IsNotExist(err) { + return nil, err + } + files, err := m.readCertFiles() + if err != nil { + return nil, err + } + state.Files = files + powConfig, err := m.readPowConfigFile() + if err != nil { + return nil, err + } + state.PowConfig = powConfig + wafConfig, err := m.readRuntimeConfigFile("waf_config.json") + if err != nil { + return nil, err + } + state.WAFConfig = wafConfig + sourceConfig, err := m.readRuntimeConfigFile(openrestyrender.SourceConfigFileName) + if err != nil { + return nil, err + } + state.SourceConfig = sourceConfig + slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files)) + return state, nil +} + +func (m *Manager) restore(state *backupState) error { + if state == nil { + return nil + } + slog.Warn("restoring nginx backup", "main_existed", state.MainExisted, "route_existed", state.RouteExisted, "cert_files", len(state.Files)) + if state.MainExisted { + if err := os.WriteFile(m.MainConfigPath, state.MainData, nginxConfigFilePerm); err != nil { + return err + } + } else if err := os.Remove(m.MainConfigPath); err != nil && !os.IsNotExist(err) { + return err + } + if state.RouteExisted { + if err := os.WriteFile(m.RouteConfigPath, state.RouteData, nginxConfigFilePerm); err != nil { + return err + } + } else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) { + return err + } + if m.CertDir != "" { + if err := m.writeManagedCertFiles(state.Files); err != nil { + return err + } + } + if err := m.restoreRuntimeConfig(state.PowConfig, powConfigFileName); err != nil { + return err + } + if err := m.restoreRuntimeConfig(state.WAFConfig, "waf_config.json"); err != nil { + return err + } + return m.restoreRuntimeConfig(state.SourceConfig, openrestyrender.SourceConfigFileName) +} + +func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error { + if m.CertDir == "" { + return nil + } + return m.writeManagedCertFiles(certFiles) +} + +// writePowConfig persists legacy pow_config.json for backward compatibility. +// PoW runtime loads site config from waf_config.json; pow_config.json is deprecated. +func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error { + if m.RuntimeConfigDir == "" { + return nil + } + configPath := filepath.Join(m.RuntimeConfigDir, powConfigFileName) + for _, file := range supportFiles { + if file.Path == powConfigFileName { + if err := os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm); err != nil { + return fmt.Errorf("write pow_config.json: %w", err) + } + slog.Info("wrote pow config", "path", configPath, "size", len(file.Content)) + return nil + } + } + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove pow_config.json: %w", err) + } + if err := removeLegacyPowConfig(filepath.Join(m.LuaDir, powConfigFileName)); err != nil { + return err + } + if err := removeLegacyPowConfig(filepath.Join(m.CertDir, powConfigFileName)); err != nil { + return err + } + return nil +} + +func (m *Manager) writeWAFConfig(supportFiles []protocol.SupportFile) error { + if m.RuntimeConfigDir == "" { + return nil + } + configPath := filepath.Join(m.RuntimeConfigDir, "waf_config.json") + for _, file := range supportFiles { + if file.Path == "waf_config.json" { + if err := os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm); err != nil { + return fmt.Errorf("write waf_config.json: %w", err) + } + slog.Info("wrote waf config", "path", configPath, "size", len(file.Content)) + return nil + } + } + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove waf_config.json: %w", err) + } + return nil +} + +func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error { + if m.RuntimeConfigDir == "" { + return nil + } + configPath := filepath.Join(m.RuntimeConfigDir, openrestyrender.SourceConfigFileName) + for _, file := range supportFiles { + if file.Path == openrestyrender.SourceConfigFileName { + if err := os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm); err != nil { + return fmt.Errorf("write %s: %w", openrestyrender.SourceConfigFileName, err) + } + slog.Info("wrote openresty source config", "path", configPath, "size", len(file.Content)) + return nil + } + } + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", openrestyrender.SourceConfigFileName, err) + } + return nil +} + +func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error { + certFiles = append(certFiles, ManagedSWLuaFiles()...) + files := make([]managedFile, 0, len(certFiles)) + for _, file := range certFiles { + if file.Path == powConfigFileName || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName { + continue + } + targetPath, err := m.certFileTargetPath(file.Path) + if err != nil { + return err + } + relativePath, err := filepath.Rel(m.CertDir, targetPath) + if err != nil { + return err + } + files = append(files, managedFile{ + Path: filepath.ToSlash(relativePath), + Content: []byte(file.Content), + Mode: certFileMode(file.Path), + }) + } + return syncManagedFiles(m.CertDir, files) +} + +func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) { + if m.CertDir == "" { + return nil, nil + } + if _, err := os.Stat(m.CertDir); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + files := make([]protocol.SupportFile, 0) + err := filepath.Walk(m.CertDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + relativePath, err := filepath.Rel(m.CertDir, path) + if err != nil { + return err + } + if filepath.ToSlash(relativePath) == powConfigFileName { + return nil + } + data, err := os.ReadFile(path) //nolint:gosec // path is under managed baseDir walk root + if err != nil { + return err + } + files = append(files, protocol.SupportFile{ + Path: filepath.ToSlash(relativePath), + Content: string(data), + }) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(files, func(i int, j int) bool { + return files[i].Path < files[j].Path + }) + return files, nil +} + +func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) { + return m.readRuntimeConfigFile(powConfigFileName) +} + +func (m *Manager) readRuntimeConfigFile(name string) (*protocol.SupportFile, error) { + if m.RuntimeConfigDir == "" { + return nil, nil + } + configPath := filepath.Join(m.RuntimeConfigDir, name) + data, err := os.ReadFile(configPath) //nolint:gosec // configPath is under managed RuntimeConfigDir + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &protocol.SupportFile{ + Path: name, + Content: string(data), + }, nil +} + +func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) { + files, err := m.readCertFiles() + if err != nil { + return nil, err + } + // Agent-shipped SW Lua assets are not part of the rendered bundle, so they + // must stay out of the bundle checksum to keep it aligned with the server. + swManaged := make(map[string]struct{}, len(ManagedSWLuaFiles())) + for _, file := range ManagedSWLuaFiles() { + swManaged[file.Path] = struct{}{} + } + kept := files[:0] + for _, file := range files { + if _, skip := swManaged[file.Path]; skip { + continue + } + kept = append(kept, file) + } + files = kept + powConfig, err := m.readPowConfigFile() + if err != nil { + return nil, err + } + if powConfig != nil { + files = append(files, *powConfig) + } + wafConfig, err := m.readRuntimeConfigFile("waf_config.json") + if err != nil { + return nil, err + } + if wafConfig != nil { + files = append(files, *wafConfig) + } + return files, nil +} + +func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string) error { + if m.RuntimeConfigDir == "" { + return nil + } + configPath := filepath.Join(m.RuntimeConfigDir, name) + if file == nil { + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + return os.WriteFile(configPath, []byte(file.Content), nginxConfigFilePerm) +} + +func (m *Manager) writeSafeDefaultFallbackFiles() error { + if strings.TrimSpace(m.MainConfigPath) == "" { + return errors.New("main config path 不能为空") + } + if strings.TrimSpace(m.RouteConfigPath) == "" { + return errors.New("route config path 不能为空") + } + if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), nginxDirPerm); err != nil { + return err + } + if err := os.WriteFile(m.RouteConfigPath, nil, nginxConfigFilePerm); err != nil { + return err + } + if err := os.WriteFile(m.MainConfigPath, []byte(m.renderMainConfig(m.safeDefaultFallbackMainConfig())), nginxConfigFilePerm); err != nil { + return err + } + return nil +} + +func (m *Manager) safeDefaultFallbackMainConfig() string { + observabilityBlock := "" + if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { + observabilityBlock = fmt.Sprintf(safeDefaultFallbackObservabilityServerBlock, listen) + } + return fmt.Sprintf(safeDefaultFallbackMainConfig, observabilityBlock) +} + +func (m *Manager) checkStubStatus(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, stubStatusCheckTimeout) + defer cancel() + openrestyStubURL := fmt.Sprintf("http://127.0.0.1:%d/openflare/stub_status", m.OpenrestyObservabilityPort) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, openrestyStubURL, nil) + if err != nil { + return err + } + resp, err := (&http.Client{}).Do(req) + if err != nil { + return fmt.Errorf("openresty health endpoint unreachable: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("openresty health endpoint returned %s", resp.Status) + } + slog.Debug("openresty health endpoint is healthy", "url", openrestyStubURL) + return nil +} + +func removeLegacyPowConfig(path string) error { + if strings.TrimSpace(path) == "" { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove legacy pow_config.json %q: %w", path, err) + } + return nil +} + +func (m *Manager) certFileTargetPath(relativePath string) (string, error) { + if strings.TrimSpace(m.CertDir) == "" { + return "", errors.New("cert dir 不能为空") + } + candidate := strings.TrimSpace(relativePath) + if strings.Contains(candidate, `\`) { + candidate = strings.ReplaceAll(candidate, `\`, "/") + } + normalizedPath := filepath.Clean(filepath.FromSlash(candidate)) + if normalizedPath == "." || normalizedPath == "" { + return "", errors.New("cert file path 不能为空") + } + if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" { + return "", fmt.Errorf("cert file path %q must be relative", relativePath) + } + targetPath := filepath.Join(m.CertDir, normalizedPath) + relativeToBase, err := filepath.Rel(m.CertDir, targetPath) + if err != nil { + return "", err + } + if relativeToBase == ".." || strings.HasPrefix(relativeToBase, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("cert file path %q escapes cert dir", relativePath) + } + return targetPath, nil +} + +func certFileMode(relativePath string) fs.FileMode { + switch strings.ToLower(filepath.Ext(strings.TrimSpace(relativePath))) { + case ".crt", ".pem": + return nginxConfigFilePerm + case ".key": + return nginxPrivateKeyFilePerm + default: + return nginxConfigFilePerm + } +} + +func luaFileTargetPath(baseDir string, relativePath string) (string, error) { + if strings.TrimSpace(baseDir) == "" { + return "", errors.New("lua dir 不能为空") + } + candidate := strings.TrimSpace(relativePath) + if strings.Contains(candidate, `\`) { + candidate = strings.ReplaceAll(candidate, `\`, "/") + } + normalizedPath := filepath.Clean(filepath.FromSlash(candidate)) + if normalizedPath == "." || normalizedPath == "" { + return "", errors.New("lua file path 不能为空") + } + if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" { + return "", fmt.Errorf("lua file path %q must be relative", relativePath) + } + targetPath := filepath.Join(baseDir, normalizedPath) + relativeToBase, err := filepath.Rel(baseDir, targetPath) + if err != nil { + return "", err + } + if relativeToBase == ".." || strings.HasPrefix(relativeToBase, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("lua file path %q escapes lua dir", relativePath) + } + return targetPath, nil +} + +func syncManagedFiles(baseDir string, files []managedFile) error { + if strings.TrimSpace(baseDir) == "" { + return errors.New("managed dir cannot be empty") + } + if info, err := os.Stat(baseDir); err == nil && !info.IsDir() { + return fmt.Errorf("managed dir %q is not a directory", baseDir) + } else if err != nil && !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(baseDir, nginxDirPerm); err != nil { + return err + } + + desired := make(map[string]managedFile, len(files)) + for _, file := range files { + cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimSpace(file.Path))) + if cleanPath == "." || cleanPath == "" { + return errors.New("managed file path cannot be empty") + } + desired[cleanPath] = managedFile{ + Path: cleanPath, + Content: file.Content, + Mode: file.Mode, + } + } + + if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + relativePath, err := filepath.Rel(baseDir, path) + if err != nil { + return err + } + if _, ok := desired[filepath.Clean(relativePath)]; ok { + return nil + } + return os.Remove(path) //nolint:gosec // path is resolved under the managed baseDir walk root + }); err != nil { + return err + } + + for _, file := range desired { + targetPath := filepath.Join(baseDir, file.Path) + if err := os.MkdirAll(filepath.Dir(targetPath), nginxDirPerm); err != nil { + return err + } + if err := os.WriteFile(targetPath, file.Content, file.Mode); err != nil { + return err + } + } + + return removeEmptyManagedDirs(baseDir) +} + +func removeEmptyManagedDirs(baseDir string) error { + dirs := make([]string, 0) + if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() && path != baseDir { + dirs = append(dirs, path) + } + return nil + }); err != nil { + return err + } + sort.Slice(dirs, func(i int, j int) bool { + return len(dirs[i]) > len(dirs[j]) + }) + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + if len(entries) == 0 { + if err := os.Remove(dir); err != nil && !os.IsNotExist(err) { + return err + } + } + } + return nil +} + +func (m *Manager) ensureMimeTypes() error { + if m.MainConfigPath == "" { + return nil + } + configDir := filepath.Dir(m.MainConfigPath) + mimeTypesPath := filepath.Join(configDir, "mime.types") + if _, err := os.Stat(mimeTypesPath); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(configDir, nginxDirPerm); err != nil { + return err + } + return os.WriteFile(mimeTypesPath, []byte(DefaultMimeTypes), nginxConfigFilePerm) +} + +func (m *Manager) renderRouteConfig(content string) string { + rendered := content + if m.NginxCertDir != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir) + errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath)) + rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorPageTmplPlaceholder, errorPagePath) + swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw")) + rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir) + } + if luaDir := m.luaRuntimePath(); luaDir != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir) + rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static") + } + if pagesDir := m.pagesRuntimePath(); pagesDir != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.PagesDirPlaceholder, pagesDir) + } + return rendered +} + +func (m *Manager) renderMainConfig(content string) string { + rendered := content + if includePath := m.routeConfigIncludePath(); includePath != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.RouteConfigPlaceholder, includePath) + } + if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.AccessLogPlaceholder, accessLogPath) + errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log") + rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorLogPlaceholder, filepath.ToSlash(errorLogPath)) + } + if pidPath := m.pidRuntimePath(); pidPath != "" { + slashPIDPath := filepath.ToSlash(pidPath) + rendered = strings.ReplaceAll(rendered, openrestyrender.PIDPathPlaceholder, slashPIDPath) + rendered = strings.ReplaceAll(rendered, "pid logs/nginx.pid;", "pid "+slashPIDPath+";") + if err := os.MkdirAll(filepath.Dir(pidPath), nginxDirPerm); err != nil { + slog.Warn("ensure nginx pid directory failed", "path", filepath.Dir(pidPath), "error", err) + } + } + if cacheDir := m.nginxCacheRuntimeDir(); cacheDir != "" { + slashCacheDir := filepath.ToSlash(cacheDir) + rendered = strings.ReplaceAll(rendered, openrestyrender.NginxCacheDirPlaceholder, slashCacheDir) + if !strings.Contains(rendered, "client_body_temp_path") { + writablePaths := fmt.Sprintf( + " client_body_temp_path %s/client_temp;\n proxy_temp_path %s/proxy_temp;\n fastcgi_temp_path %s/fastcgi_temp;\n uwsgi_temp_path %s/uwsgi_temp;\n scgi_temp_path %s/scgi_temp;\n", + slashCacheDir, slashCacheDir, slashCacheDir, slashCacheDir, slashCacheDir, + ) + rendered = strings.Replace(rendered, "http {", "http {\n"+writablePaths, 1) + } + for _, subDir := range []string{"client_temp", "proxy_temp", "fastcgi_temp", "uwsgi_temp", "scgi_temp"} { + if err := os.MkdirAll(filepath.Join(cacheDir, subDir), nginxDirPerm); err != nil { + slog.Warn("ensure nginx cache directory failed", "path", filepath.Join(cacheDir, subDir), "error", err) + } + } + } + if proxyCacheDir := m.proxyCacheRuntimeDir(); proxyCacheDir != "" { + slashProxyCache := filepath.ToSlash(proxyCacheDir) + rendered = strings.ReplaceAll(rendered, openrestyrender.ProxyCachePathPlaceholder, slashProxyCache) + rendered = strings.ReplaceAll(rendered, "/var/cache/openresty", slashProxyCache) + if err := os.MkdirAll(proxyCacheDir, nginxDirPerm); err != nil { + slog.Warn("ensure proxy cache directory failed", "path", proxyCacheDir, "error", err) + } + } + if luaDir := m.luaRuntimePath(); luaDir != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir) + if strings.Contains(rendered, "lua_shared_dict openflare_waf_config") { + rendered = injectWAFWorkerInit(rendered, luaDir) + } + } + if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityListenPlaceholder, listen) + } + if m.OpenrestyObservabilityPort > 0 { + rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityPortPlaceholder, strconv.Itoa(m.OpenrestyObservabilityPort)) + } + if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" { + rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective) + } + return rendered +} + +func injectWAFWorkerInit(content string, luaDir string) string { + packagePath := fmt.Sprintf(" lua_package_path \"%s/?.lua;%s/?/init.lua;;\";\n", luaDir, luaDir) + if !strings.Contains(content, "lua_package_path ") { + content = strings.Replace(content, "http {", "http {\n"+packagePath, 1) + } + initPattern := regexp.MustCompile(`(?m)^[ \t]*init_worker_by_lua_file[ \t]+([^;]+);[ \t]*$`) + if match := initPattern.FindStringSubmatch(content); len(match) == workerInitSubmatchCount { + block := fmt.Sprintf(" init_worker_by_lua_block {\n require(\"waf.runtime\").init()\n dofile(%q)\n }", strings.TrimSpace(match[1])) + return initPattern.ReplaceAllString(content, block) + } + return strings.Replace(content, "http {", "http {\n init_worker_by_lua_block { require(\"waf.runtime\").init() }", 1) +} + +func (m *Manager) managedPowLuaFiles() []protocol.SupportFile { + files := ManagedPowLuaFiles() + runtimeConfigDir := filepath.ToSlash(strings.TrimSpace(m.RuntimeConfigDir)) + for index := range files { + files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir) + } + return files +} + +func (m *Manager) managedWAFLuaFiles() []protocol.SupportFile { + files := ManagedWAFLuaFiles() + runtimeConfigDir := filepath.ToSlash(strings.TrimSpace(m.RuntimeConfigDir)) + countryMMDBPath := filepath.ToSlash(strings.TrimSpace(m.MMDBPath)) + if countryMMDBPath == "" { + countryMMDBPath = filepath.ToSlash(filepath.Join(runtimeConfigDir, "GeoLite2-Country.mmdb")) + } + cityMMDBPath := filepath.ToSlash(strings.TrimSpace(m.CityMMDBPath)) + if cityMMDBPath == "" { + cityMMDBPath = filepath.ToSlash(filepath.Join(runtimeConfigDir, "GeoLite2-City.mmdb")) + } + for index := range files { + files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir) + files[index].Content = strings.ReplaceAll(files[index].Content, CountryMMDBPathPlaceholder, countryMMDBPath) + files[index].Content = strings.ReplaceAll(files[index].Content, CityMMDBPathPlaceholder, cityMMDBPath) + files[index].Content = strings.ReplaceAll(files[index].Content, WAFIPGroupsMaxSnapshotBytesPlaceholder, strconv.Itoa(sharedprotocol.MaxWAFIPGroupSnapshotBytes)) + } + return files +} + +// ObservabilityListenAddress returns the localhost listen address for stub_status. +func ObservabilityListenAddress(port int) string { + if port <= 0 { + return "" + } + return fmt.Sprintf("127.0.0.1:%d", port) +} + +// ResolverDirective renders the nginx resolver block for runtime upstream lookups. +func ResolverDirective(explicitResolvers []string) string { + resolvers := resolverAddresses(explicitResolvers) + if len(resolvers) == 0 { + return "" + } + return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " ")) +} + +func resolverAddresses(explicitResolvers []string) []string { + if resolvers := ofutil.UniqueAndCleanStringSlice(explicitResolvers); len(resolvers) > 0 { + return resolvers + } + data, err := os.ReadFile("/etc/resolv.conf") + if err != nil { + return nil + } + return parseResolverAddresses(string(data), false) +} + +func parseResolverAddresses(content string, dockerMode bool) []string { + lines := strings.Split(content, "\n") + resolvers := make([]string, 0, resolverAddressCapacity) + seen := make(map[string]struct{}) + for _, line := range lines { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 || fields[0] != "nameserver" { + continue + } + addr := strings.TrimSpace(fields[1]) + if addr == "" { + continue + } + if dockerMode && !isUsableDockerResolver(addr) { + continue + } + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + resolvers = append(resolvers, addr) + } + return resolvers +} + +func isUsableDockerResolver(addr string) bool { + ip := net.ParseIP(addr) + if ip == nil { + return false + } + return !ip.IsLoopback() && !ip.IsUnspecified() +} + +// RequiresRuntimeResolver reports whether originURL needs a runtime DNS resolver. +func RequiresRuntimeResolver(originURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(originURL)) + if err != nil || parsed.Hostname() == "" { + return false + } + return net.ParseIP(parsed.Hostname()) == nil +} + +func (m *Manager) routeConfigIncludePath() string { + return strings.TrimSpace(m.RouteConfigPath) +} + +func (m *Manager) accessLogRuntimePath() string { + return filepath.ToSlash(strings.TrimSpace(m.AccessLogPath)) +} + +func (m *Manager) varRuntimeDir() string { + if accessLogPath := strings.TrimSpace(m.AccessLogPath); accessLogPath != "" { + return filepath.Dir(filepath.Dir(filepath.Dir(accessLogPath))) + } + if mainConfigPath := strings.TrimSpace(m.MainConfigPath); mainConfigPath != "" { + return filepath.Clean(filepath.Join(filepath.Dir(mainConfigPath), "..", "..")) + } + return "" +} + +func (m *Manager) pidRuntimePath() string { + if varRoot := m.varRuntimeDir(); varRoot != "" { + return filepath.ToSlash(filepath.Join(varRoot, "run", "nginx.pid")) + } + return "" +} + +func (m *Manager) nginxCacheRuntimeDir() string { + if varRoot := m.varRuntimeDir(); varRoot != "" { + return filepath.ToSlash(filepath.Join(varRoot, "cache", "nginx")) + } + return "" +} + +func (m *Manager) proxyCacheRuntimeDir() string { + if varRoot := m.varRuntimeDir(); varRoot != "" { + return filepath.Join(varRoot, "cache", "openflare_proxy") + } + return "" +} + +func (m *Manager) luaRuntimePath() string { + if strings.TrimSpace(m.NginxLuaDir) == "" { + return "" + } + return filepath.ToSlash(m.NginxLuaDir) +} + +func (m *Manager) pagesRuntimePath() string { + return filepath.ToSlash(strings.TrimSpace(m.PagesDir)) +} + +func checksum(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:]) +} + +func bundleChecksum(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) string { + files := append([]protocol.SupportFile(nil), supportFiles...) + sort.Slice(files, func(i int, j int) bool { + return files[i].Path < files[j].Path + }) + var builder strings.Builder + builder.WriteString(mainConfig) + builder.WriteString("\n--route-config--\n") + builder.WriteString(routeConfig) + builder.WriteString("\n--support-files--\n") + for _, file := range files { + builder.WriteString(file.Path) + builder.WriteString("\n") + builder.WriteString(file.Content) + builder.WriteString("\n") + } + return checksum(builder.String()) +} diff --git a/backend/openflare/plugins/agent/nginx/manager_test.go b/backend/openflare/plugins/agent/nginx/manager_test.go new file mode 100644 index 00000000..c994af48 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/manager_test.go @@ -0,0 +1,1409 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "Wavelet/openflare/plugins/agent/protocol" + sharedprotocol "Wavelet/openflare/share/protocol" + openrestyrender "Wavelet/openflare/share/render/openresty" +) + +type runCall struct { + name string + args []string +} + +type fakeRunner struct { + calls []runCall + runFn func(name string, args ...string) ([]byte, error) +} + +type fakeExecutor struct { + testErr error + reloadErr error +} + +type scriptedExecutor struct { + testErrors []error + testCalls int + reloadErrors []error + reloadCalls int +} + +func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)}) + if r.runFn != nil { + return r.runFn(name, args...) + } + return nil, nil +} + +func (e *fakeExecutor) Test(ctx context.Context) error { + return e.testErr +} + +func (e *fakeExecutor) Reload(ctx context.Context) error { + return e.reloadErr +} + +func (e *fakeExecutor) EnsureRuntime(ctx context.Context, recreate bool) error { + return nil +} + +func (e *fakeExecutor) CheckHealth(ctx context.Context) error { + return e.testErr +} + +func (e *fakeExecutor) Restart(ctx context.Context) error { + return e.reloadErr +} + +func (e *scriptedExecutor) Test(ctx context.Context) error { + index := e.testCalls + e.testCalls++ + if index >= len(e.testErrors) { + return nil + } + return e.testErrors[index] +} + +func (e *scriptedExecutor) Reload(ctx context.Context) error { + index := e.reloadCalls + e.reloadCalls++ + if index >= len(e.reloadErrors) { + return nil + } + return e.reloadErrors[index] +} + +func (e *scriptedExecutor) EnsureRuntime(ctx context.Context, recreate bool) error { + return nil +} + +func (e *scriptedExecutor) CheckHealth(ctx context.Context) error { + return nil +} + +func (e *scriptedExecutor) Restart(ctx context.Context) error { + return nil +} + +func TestPathExecutorCommands(t *testing.T) { + runner := &fakeRunner{} + executor := &PathExecutor{ + Path: "/usr/local/openresty/nginx/sbin/openresty", + ConfigPath: "/data/etc/nginx/nginx.conf", + Runner: runner, + } + + if err := executor.Test(context.Background()); err != nil { + t.Fatalf("Test failed: %v", err) + } + if err := executor.Reload(context.Background()); err != nil { + t.Fatalf("Reload failed: %v", err) + } + + expected := []runCall{ + {name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-t", "-c", "/data/etc/nginx/nginx.conf"}}, + {name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}}, + } + if !reflect.DeepEqual(runner.calls, expected) { + t.Fatalf("unexpected calls: %#v", runner.calls) + } +} + +func TestPathExecutorEnsureRuntimeNoop(t *testing.T) { + runner := &fakeRunner{} + executor := &PathExecutor{ + Path: "/usr/local/openresty/nginx/sbin/openresty", + ConfigPath: "/data/etc/nginx/nginx.conf", + Runner: runner, + } + if err := executor.EnsureRuntime(context.Background(), true); err != nil { + t.Fatalf("EnsureRuntime failed: %v", err) + } + if len(runner.calls) != 2 { + t.Fatalf("expected test and reload calls, got %d", len(runner.calls)) + } +} + +func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) { + runner := &fakeRunner{ + runFn: func(name string, args ...string) ([]byte, error) { + if len(args) == 2 && args[0] == "-s" && args[1] == "quit" { + return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1") + } + return []byte(""), nil + }, + } + executor := &PathExecutor{ + Path: "/usr/local/openresty/nginx/sbin/openresty", + ConfigPath: "/data/etc/nginx/nginx.conf", + Runner: runner, + } + if err := executor.Restart(context.Background()); err != nil { + t.Fatalf("Restart failed: %v", err) + } + if len(runner.calls) != 2 { + t.Fatalf("expected 2 restart calls, got %d", len(runner.calls)) + } +} + +func TestPathExecutorReloadStartsWhenRuntimeIsNotRunning(t *testing.T) { + runner := &fakeRunner{ + runFn: func(name string, args ...string) ([]byte, error) { + if len(args) >= 2 && args[0] == "-s" && args[1] == "reload" { + return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1") + } + return []byte(""), nil + }, + } + executor := &PathExecutor{ + Path: "/usr/local/openresty/nginx/sbin/openresty", + ConfigPath: "/data/etc/nginx/nginx.conf", + Runner: runner, + } + if err := executor.Reload(context.Background()); err != nil { + t.Fatalf("Reload failed: %v", err) + } + expected := []runCall{ + {name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}}, + {name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-c", "/data/etc/nginx/nginx.conf"}}, + } + if !reflect.DeepEqual(runner.calls, expected) { + t.Fatalf("unexpected calls: %#v", runner.calls) + } +} + +func TestDetectVersionFromBinary(t *testing.T) { + version, err := detectVersion(context.Background(), ExecutorOptions{ + NginxPath: "/usr/local/openresty/nginx/sbin/openresty", + }, &fakeRunner{ + runFn: func(name string, args ...string) ([]byte, error) { + return []byte("nginx version: openresty/1.27.1.2\n"), nil + }, + }) + if err != nil { + t.Fatalf("detectVersion failed: %v", err) + } + if version != "1.27.1.2" { + t.Fatalf("unexpected version: %s", version) + } +} + +func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) { + tempDir := t.TempDir() + mainPath := filepath.Join(tempDir, "nginx.conf") + routePath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf") + certDir := filepath.Join(tempDir, "certs") + accessLogPath := filepath.Join(tempDir, "var", "log", "openflare", "access.log") + manager := &Manager{ + MainConfigPath: mainPath, + RouteConfigPath: routePath, + AccessLogPath: accessLogPath, + CertDir: certDir, + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + Executor: &fakeExecutor{}, + } + + outcome := manager.Apply( + context.Background(), + "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", + "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;\n", + []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + ) + if outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) + } + + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(accessLogPath) + " openflare_json;\n" + if string(mainData) != expectedMain { + t.Fatalf("unexpected main config: %s", string(mainData)) + } + + routeData, err := os.ReadFile(routePath) + if err != nil { + t.Fatalf("failed to read route config: %v", err) + } + if string(routeData) != "ssl_certificate /etc/nginx/openflare-certs/1.crt;\n" { + t.Fatalf("unexpected route config: %s", string(routeData)) + } + + value, err := manager.CurrentChecksum() + if err != nil { + t.Fatalf("CurrentChecksum failed: %v", err) + } + expected := bundleChecksum( + "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", + "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;\n", + []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + ) + if value != expected { + t.Fatalf("unexpected checksum: got %s want %s", value, expected) + } +} + +func TestParseExtVersionIgnoresDockerEntrypointPaths(t *testing.T) { + output := strings.Join([]string{ + "/docker-entrypoint.sh: /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)", + "nginx version: openresty/1.27.1.2", + }, "\n") + + version := parseExtVersion(output) + if version != "1.27.1.2" { + t.Fatalf("unexpected version: %s", version) + } +} + +func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) { + tempDir := t.TempDir() + manager := &Manager{ + MainConfigPath: filepath.Join(tempDir, "nginx.conf"), + RouteConfigPath: filepath.Join(tempDir, "routes.conf"), + CertDir: filepath.Join(tempDir, "certs"), + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + OpenrestyObservabilityListen: "18081", + OpenrestyResolverDirective: " resolver 127.0.0.11 valid=30s ipv6=off;\n resolver_timeout 5s;\n", + Executor: &fakeExecutor{}, + } + + outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\n__OPENFLARE_RESOLVER_DIRECTIVE__server { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{ + {Path: "1.crt", Content: "cert-data"}, + {Path: "1.key", Content: "key-data"}, + }) + if outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) + } + + routeData, err := os.ReadFile(manager.RouteConfigPath) + if err != nil { + t.Fatalf("failed to read route config: %v", err) + } + if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") { + t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData)) + } + renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\nio.open(\"__OPENFLARE_ERROR_PAGE_TMPL__\", \"r\")\n") + if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") { + t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute) + } + if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") { + t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute) + } + wantErrorPagePath := filepath.ToSlash(filepath.Join(manager.NginxCertDir, openrestyrender.OriginErrorPageSupportPath)) + if !strings.Contains(renderedRoute, `io.open("`+wantErrorPagePath+`", "r")`) { + t.Fatalf("expected error page template placeholder replacement, got %s", renderedRoute) + } + mainData, err := os.ReadFile(manager.MainConfigPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if !strings.Contains(string(mainData), "listen 18081;") { + t.Fatalf("expected observability listen placeholder replacement in main config, got %s", string(mainData)) + } + if !strings.Contains(string(mainData), "resolver 127.0.0.11 valid=30s ipv6=off;") { + t.Fatalf("expected resolver directive placeholder replacement in main config, got %s", string(mainData)) + } + certData, err := os.ReadFile(filepath.Join(manager.CertDir, "1.crt")) + if err != nil { + t.Fatalf("failed to read cert file: %v", err) + } + if string(certData) != "cert-data" { + t.Fatalf("unexpected cert file content: %s", string(certData)) + } + luaInfo, err := os.Stat(filepath.Join(manager.LuaDir, "log.lua")) + if err != nil { + t.Fatalf("expected managed lua file to exist, stat err = %v", err) + } + if runtime.GOOS != "windows" && luaInfo.Mode().Perm() != 0o644 { + t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm()) + } +} + +func TestManagerApplyShipsSWAssetsAndReplacesSWDirPlaceholder(t *testing.T) { + tempDir := t.TempDir() + manager := &Manager{ + MainConfigPath: filepath.Join(tempDir, "nginx.conf"), + RouteConfigPath: filepath.Join(tempDir, "routes.conf"), + CertDir: filepath.Join(tempDir, "certs"), + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + Executor: &fakeExecutor{}, + } + + outcome := manager.Apply( + context.Background(), + "include __OPENFLARE_ROUTE_CONFIG__;", + "alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")", + []protocol.SupportFile{ + {Path: "sw/sw.js", Content: "js"}, + {Path: "sw/offline.html", Content: "html"}, + }, + ) + if outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) + } + + routeData, err := os.ReadFile(manager.RouteConfigPath) + if err != nil { + t.Fatalf("failed to read route config: %v", err) + } + rendered := string(routeData) + for _, want := range []string{ + "/etc/nginx/openflare-certs/sw/sw.js", + "/etc/nginx/openflare-certs/sw/offline.html", + "/etc/nginx/openflare-certs/sw/challenge.lua", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("route config missing %q, got %s", want, rendered) + } + } + if strings.Contains(rendered, openrestyrender.SWDirPlaceholder) { + t.Fatalf("route config still contains SW dir placeholder: %s", rendered) + } + + for _, path := range []string{ + filepath.Join(manager.CertDir, "sw", "sw.js"), + filepath.Join(manager.CertDir, "sw", "offline.html"), + filepath.Join(manager.CertDir, "sw", "challenge.lua"), + filepath.Join(manager.CertDir, "sw", "runtime.lua"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected SW asset %s to exist: %v", path, err) + } + } + for _, path := range []string{ + filepath.Join(manager.LuaDir, "sw", "challenge.lua"), + filepath.Join(manager.LuaDir, "sw", "runtime.lua"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected SW lua asset %s to exist: %v", path, err) + } + } + + checksum, err := manager.CurrentChecksum() + if err != nil { + t.Fatalf("CurrentChecksum failed: %v", err) + } + expected := bundleChecksum( + "include __OPENFLARE_ROUTE_CONFIG__;", + "alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")", + []protocol.SupportFile{ + {Path: "sw/sw.js", Content: "js"}, + {Path: "sw/offline.html", Content: "html"}, + }, + ) + if checksum != expected { + t.Fatalf("unexpected checksum: got %s want %s", checksum, expected) + } +} + +func TestManagerRenderMainConfigInitializesWAFRuntimeInWorker(t *testing.T) { + manager := &Manager{NginxLuaDir: "/etc/nginx/openflare-lua"} + rendered := manager.renderMainConfig("events {}\nhttp {\n lua_shared_dict openflare_waf_config 1m;\n server {}\n}\n") + want := `init_worker_by_lua_block { require("waf.runtime").init() }` + if !strings.Contains(rendered, want) { + t.Fatalf("expected worker-time WAF initialization %q, got:\n%s", want, rendered) + } +} + +func TestManagerRenderMainConfigMergesExistingWorkerInitializer(t *testing.T) { + manager := &Manager{NginxLuaDir: "/etc/nginx/openflare-lua"} + rendered := manager.renderMainConfig("http {\n lua_shared_dict openflare_waf_config 1m;\n init_worker_by_lua_file /etc/nginx/openflare-lua/observability/init.lua;\n}\n") + if strings.Count(rendered, "init_worker_by_lua_") != 1 { + t.Fatalf("expected one merged worker initializer, got:\n%s", rendered) + } + if !strings.Contains(rendered, `require("waf.runtime").init()`) { + t.Fatalf("expected WAF initialization in merged block, got:\n%s", rendered) + } + if !strings.Contains(rendered, `dofile("/etc/nginx/openflare-lua/observability/init.lua")`) { + t.Fatalf("expected existing worker initializer to be preserved, got:\n%s", rendered) + } +} + +func TestManagerCheckHealthUsesStubStatusInsteadOfConfigTest(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen failed: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/openflare/stub_status" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Active connections: 1\n")) + }), + } + go func() { + _ = server.Serve(listener) + }() + defer server.Shutdown(context.Background()) + + mainPath := filepath.Join(t.TempDir(), "nginx.conf") + if err := os.WriteFile(mainPath, []byte("main"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{ + MainConfigPath: mainPath, + OpenrestyObservabilityPort: port, + Executor: &fakeExecutor{ + testErr: errors.New("openresty -t should not be called"), + }, + } + if err := manager.CheckHealth(context.Background()); err != nil { + t.Fatalf("CheckHealth failed: %v", err) + } +} + +func TestManagerCheckHealthFailsWhenStubStatusUnavailable(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen failed: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("listener close failed: %v", err) + } + + mainPath := filepath.Join(t.TempDir(), "nginx.conf") + if err := os.WriteFile(mainPath, []byte("main"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{ + MainConfigPath: mainPath, + OpenrestyObservabilityPort: port, + Executor: &fakeExecutor{}, + } + if err := manager.CheckHealth(context.Background()); err == nil { + t.Fatal("expected CheckHealth to fail when stub_status is unavailable") + } +} + +func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) { + got := ResolverDirective([]string{"10.0.0.2", "1.1.1.1"}) + if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") { + t.Fatalf("expected explicit resolver directive, got %q", got) + } +} + +func TestParseResolverAddressesFiltersLoopbackForDocker(t *testing.T) { + content := strings.Join([]string{ + "nameserver 127.0.0.53", + "nameserver 10.0.0.2", + "nameserver ::1", + "nameserver 1.1.1.1", + }, "\n") + got := parseResolverAddresses(content, true) + expected := []string{"10.0.0.2", "1.1.1.1"} + if !reflect.DeepEqual(got, expected) { + t.Fatalf("unexpected docker resolvers: got %#v want %#v", got, expected) + } +} + +func TestParseResolverAddressesKeepsLoopbackForLocalBinary(t *testing.T) { + content := strings.Join([]string{ + "nameserver 127.0.0.53", + "nameserver 10.0.0.2", + }, "\n") + got := parseResolverAddresses(content, false) + expected := []string{"127.0.0.53", "10.0.0.2"} + if !reflect.DeepEqual(got, expected) { + t.Fatalf("unexpected local resolvers: got %#v want %#v", got, expected) + } +} + +func TestRequiresRuntimeResolver(t *testing.T) { + testCases := []struct { + name string + originURL string + want bool + }{ + {name: "hostname", originURL: "https://origin.internal", want: true}, + {name: "ipv4", originURL: "https://10.0.0.8", want: false}, + {name: "ipv6", originURL: "https://[2001:db8::1]", want: false}, + {name: "invalid", originURL: "://bad", want: false}, + } + + for _, testCase := range testCases { + if got := RequiresRuntimeResolver(testCase.originURL); got != testCase.want { + t.Fatalf("%s: got %v want %v", testCase.name, got, testCase.want) + } + } +} + +func TestWriteCertFilesKeepsBaseDirAndRemovesStaleFiles(t *testing.T) { + tempDir := t.TempDir() + certDir := filepath.Join(tempDir, "certs") + if err := os.MkdirAll(filepath.Join(certDir, "stale"), 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(certDir, "stale", "old.crt"), []byte("old"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{CertDir: certDir} + + if err := manager.writeCertFiles([]protocol.SupportFile{ + {Path: "1.crt", Content: "cert"}, + {Path: "1.key", Content: "key"}, + }); err != nil { + t.Fatalf("writeCertFiles failed: %v", err) + } + + if _, err := os.Stat(certDir); err != nil { + t.Fatalf("expected cert dir to persist, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(certDir, "stale", "old.crt")); !os.IsNotExist(err) { + t.Fatalf("expected stale cert file to be removed, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(certDir, "1.crt")); err != nil { + t.Fatalf("expected new cert file to exist, stat err = %v", err) + } +} + +func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) { + tempDir := t.TempDir() + luaDir := filepath.Join(tempDir, "lua") + if err := os.MkdirAll(filepath.Join(luaDir, "stale"), 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(luaDir, "stale", "old.lua"), []byte("old"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{LuaDir: luaDir} + + if err := manager.EnsureLuaAssets(); err != nil { + t.Fatalf("EnsureLuaAssets failed: %v", err) + } + + if _, err := os.Stat(luaDir); err != nil { + t.Fatalf("expected lua dir to persist, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(luaDir, "stale", "old.lua")); !os.IsNotExist(err) { + t.Fatalf("expected stale lua file to be removed, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil { + t.Fatalf("expected managed lua file to exist, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(luaDir, "pow", "check.lua")); err != nil { + t.Fatalf("expected managed pow lua file to exist, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(luaDir, "pow", "static", "js", "main.mjs")); err != nil { + t.Fatalf("expected managed pow static asset to exist, stat err = %v", err) + } +} + +func TestCertFileMode(t *testing.T) { + testCases := []struct { + path string + want os.FileMode + }{ + {path: "1.crt", want: 0o644}, + {path: "1.pem", want: 0o644}, + {path: "1.key", want: 0o600}, + {path: "misc.txt", want: 0o644}, + } + + for _, testCase := range testCases { + if got := certFileMode(testCase.path); got != testCase.want { + t.Fatalf("unexpected mode for %s: got %o want %o", testCase.path, got, testCase.want) + } + } +} + +func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) { + tempDir := t.TempDir() + manager := &Manager{ + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + RuntimeConfigDir: filepath.Join(tempDir, "runtime"), + } + + err := manager.EnsureLuaAssets() + if err != nil { + t.Fatalf("EnsureLuaAssets failed: %v", err) + } + + luaInfo, err := os.Stat(filepath.Join(manager.LuaDir, "log.lua")) + if err != nil { + t.Fatalf("failed to stat lua file: %v", err) + } + if luaInfo.Mode().Perm() != 0o644 { + t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm()) + } + if _, err := os.Stat(filepath.Join(manager.LuaDir, "pow", "check.lua")); err != nil { + t.Fatalf("failed to stat pow lua file: %v", err) + } + data, err := os.ReadFile(filepath.Join(manager.LuaDir, "waf", "runtime.lua")) + if err != nil { + t.Fatalf("failed to read pow lua file: %v", err) + } + if !strings.Contains(string(data), filepath.ToSlash(manager.RuntimeConfigDir)) || !strings.Contains(string(data), `runtime_dir .. "/waf_config.json"`) { + t.Fatalf("expected WAF runtime to load its worker snapshot from the runtime config dir, got %s", string(data)) + } + ipGroupsData, err := os.ReadFile(filepath.Join(manager.LuaDir, "waf", "ip_groups.lua")) + if err != nil { + t.Fatalf("failed to read WAF IP group refresh module: %v", err) + } + if !strings.Contains(string(ipGroupsData), filepath.ToSlash(manager.RuntimeConfigDir)) || !strings.Contains(string(ipGroupsData), "waf_ip_groups.json.checksum") { + t.Fatalf("expected IP group module to use the managed runtime checksum path, got %s", string(ipGroupsData)) + } + if !strings.Contains(string(ipGroupsData), "openflare_waf_ip_groups") || + !strings.Contains(string(ipGroupsData), fmt.Sprintf("max_snapshot_bytes = options.max_snapshot_bytes or tonumber(\"%d\")", sharedprotocol.MaxWAFIPGroupSnapshotBytes)) { + t.Fatalf("expected dedicated dictionary and shared protocol size limit in deployed IP group module, got %s", string(ipGroupsData)) + } + powData, err := os.ReadFile(filepath.Join(manager.LuaDir, "pow", "runtime.lua")) + if err != nil { + t.Fatalf("failed to read pow lua file: %v", err) + } + if strings.Contains(string(powData), "io.open") { + t.Fatalf("expected PoW node evaluation not to read configuration files, got %s", string(powData)) + } +} + +func TestManagerEnsureLuaAssetsUsesConfiguredGeoIPDatabasePaths(t *testing.T) { + tempDir := t.TempDir() + manager := &Manager{ + LuaDir: filepath.Join(tempDir, "lua"), + MMDBPath: "/custom/GeoLite2-Country.mmdb", + CityMMDBPath: "/custom/GeoLite2-City.mmdb", + } + if err := manager.EnsureLuaAssets(); err != nil { + t.Fatalf("EnsureLuaAssets failed: %v", err) + } + data, err := os.ReadFile(filepath.Join(manager.LuaDir, "waf", "runtime.lua")) + if err != nil { + t.Fatalf("read WAF runtime: %v", err) + } + for _, path := range []string{manager.MMDBPath, manager.CityMMDBPath} { + if !strings.Contains(string(data), path) { + t.Fatalf("expected configured GeoIP path %q in WAF runtime", path) + } + } +} + +func TestEnsureLuaAssetsLeavesRuntimePowConfigOutsideLuaDir(t *testing.T) { + tempDir := t.TempDir() + luaDir := filepath.Join(tempDir, "lua") + runtimeConfigDir := filepath.Join(tempDir, "runtime") + if err := os.MkdirAll(runtimeConfigDir, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + powConfigPath := filepath.Join(runtimeConfigDir, "pow_config.json") + want := `[{"domains":["pow.example.com"],"enabled":true}]` + if err := os.WriteFile(powConfigPath, []byte(want), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{LuaDir: luaDir, RuntimeConfigDir: runtimeConfigDir} + + if err := manager.EnsureLuaAssets(); err != nil { + t.Fatalf("EnsureLuaAssets failed: %v", err) + } + + got, err := os.ReadFile(powConfigPath) + if err != nil { + t.Fatalf("expected pow_config.json to remain after EnsureLuaAssets: %v", err) + } + if string(got) != want { + t.Fatalf("unexpected pow_config.json content: got %s want %s", string(got), want) + } + if _, err := os.Stat(filepath.Join(luaDir, "pow_config.json")); !os.IsNotExist(err) { + t.Fatalf("expected lua pow_config.json to stay absent, stat err = %v", err) + } +} + +func TestManagerApplyWritesPowConfigToRuntimeDirAndCleansLegacyCopies(t *testing.T) { + tempDir := t.TempDir() + certDir := filepath.Join(tempDir, "certs") + luaDir := filepath.Join(tempDir, "lua") + runtimeConfigDir := filepath.Join(tempDir, "runtime") + for _, dir := range []string{certDir, luaDir, runtimeConfigDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + } + for _, path := range []string{filepath.Join(certDir, "pow_config.json"), filepath.Join(luaDir, "pow_config.json")} { + if err := os.WriteFile(path, []byte("stale"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + } + manager := &Manager{ + MainConfigPath: filepath.Join(tempDir, "nginx.conf"), + RouteConfigPath: filepath.Join(tempDir, "routes.conf"), + CertDir: certDir, + LuaDir: luaDir, + RuntimeConfigDir: runtimeConfigDir, + Executor: &fakeExecutor{}, + } + outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{ + {Path: "pow_config.json", Content: "runtime"}, + }) + if outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) + } + data, err := os.ReadFile(filepath.Join(runtimeConfigDir, "pow_config.json")) + if err != nil { + t.Fatalf("failed to read runtime pow config: %v", err) + } + if string(data) != "runtime" { + t.Fatalf("unexpected runtime pow config: %s", string(data)) + } + for _, path := range []string{filepath.Join(certDir, "pow_config.json"), filepath.Join(luaDir, "pow_config.json")} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected legacy pow config to be removed from %s, stat err = %v", path, err) + } + } +} + +func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) { + tempDir := t.TempDir() + mainPath := filepath.Join(tempDir, "nginx.conf") + routePath := filepath.Join(tempDir, "routes.conf") + luaDir := filepath.Join(tempDir, "lua") + runtimeConfigDir := filepath.Join(tempDir, "runtime") + manager := &Manager{ + MainConfigPath: mainPath, + RouteConfigPath: routePath, + LuaDir: luaDir, + NginxLuaDir: "/etc/nginx/openflare-lua", + RuntimeConfigDir: runtimeConfigDir, + Executor: &fakeExecutor{}, + } + + outcome := manager.Apply( + context.Background(), + "access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", + "location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n", + []protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}}, + ) + if outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) + } + + value, err := manager.CurrentChecksum() + if err != nil { + t.Fatalf("CurrentChecksum failed: %v", err) + } + expected := bundleChecksum( + "access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", + "location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n", + []protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}}, + ) + if value != expected { + t.Fatalf("unexpected checksum with pow config: got %s want %s", value, expected) + } +} + +func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) { + if !strings.Contains(openRestyPowRuntimeLua, `ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge", challenge_args)`) { + t.Fatal("expected pow runtime lua to internally execute make-challenge instead of issuing a 302 redirect") + } + if strings.Contains(openRestyPowRuntimeLua, "ngx.redirect(") { + t.Fatal("expected pow runtime lua to avoid external redirects for challenge rendering") + } + if !strings.Contains(openRestyPowChallengeLua, `

`) { + t.Fatal("expected challenge html to include Anubis-compatible title node") + } + if !strings.Contains(openRestyPowChallengeLua, `
`) { + t.Fatal("expected challenge html to include Anubis-compatible progress markup") + } + if !strings.Contains(openRestyPowChallengeLua, ``) { + t.Fatal("expected challenge html to force Anubis frontend to reuse the current URL as redir target") + } + if !strings.Contains(openRestyPowRuntimeLua, `pow_sessions:set(session_key, "1", session_ttl)`) { + t.Fatal("expected pow runtime lua to refresh the PoW session TTL on each valid request") + } + if !strings.Contains(openRestyPowRuntimeLua, `ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)`) { + t.Fatal("expected pow runtime lua to refresh the browser session cookie on each valid request") + } + if !strings.Contains(openRestyPowChallengeLua, `local session_ttl = config.session_ttl or 600`) { + t.Fatal("expected challenge.lua to default session TTL to 10 minutes") + } + if !strings.Contains(openRestyPowVerifyLua, `local session_ttl = challenge_info.session_ttl or 600`) { + t.Fatal("expected verify.lua to default session TTL to 10 minutes") + } + if !strings.Contains(openRestyPowVerifyLua, `if ngx.var.scheme == "https" then`) { + t.Fatal("expected verify.lua to only mark the session cookie as Secure for HTTPS requests") + } +} + +func TestManagedPowLuaFilesPreserveConfigAcrossInternalRedirect(t *testing.T) { + for _, expected := range []string{ + `pow_config_dict:set(config_key, cjson.encode(config)`, + `openflare_pow_config_key = config_key`, + `return false`, + } { + if !strings.Contains(openRestyPowRuntimeLua, expected) { + t.Fatalf("expected PoW runtime to contain %q", expected) + } + } + if !strings.Contains(openRestyPowChallengeLua, `pow_config_dict:get(config_key)`) { + t.Fatal("expected challenge handler to restore reached PoW node config after internal redirect") + } +} + +func TestManagedWAFLuaExecutesCompiledGraphWithoutRequestIO(t *testing.T) { + if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "ip_match"`) { + t.Fatal("expected WAF runtime to execute compiled IP match nodes") + } + if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "ua_check"`) { + t.Fatal("expected WAF runtime to execute compiled UA check nodes") + } + if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "security_check"`) { + t.Fatal("expected WAF runtime to execute compiled security check nodes") + } + checkStart := strings.Index(openRestyWAFRuntimeLua, "function _M.check()") + if checkStart < 0 || strings.Contains(openRestyWAFRuntimeLua[checkStart:], "io.open") { + t.Fatal("expected WAF request path not to perform file I/O") + } +} + +func TestManagerRollbackRestoresCertFiles(t *testing.T) { + tempDir := t.TempDir() + routePath := filepath.Join(tempDir, "routes.conf") + mainPath := filepath.Join(tempDir, "nginx.conf") + certDir := filepath.Join(tempDir, "certs") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(mainPath, []byte("old-main"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(filepath.Join(certDir, "1.crt"), []byte("old-cert"), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{ + MainConfigPath: mainPath, + RouteConfigPath: routePath, + CertDir: certDir, + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + Executor: &fakeExecutor{ + reloadErr: errors.New("openresty reload failed"), + }, + } + + outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{ + {Path: "1.crt", Content: "new-cert"}, + }) + if outcome.Status != ApplyStatusFatal { + t.Fatalf("expected fatal apply outcome, got %#v", outcome) + } + + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if string(mainData) != "old-main" { + t.Fatalf("expected main rollback, got %s", string(mainData)) + } + routeData, err := os.ReadFile(routePath) + if err != nil { + t.Fatalf("failed to read route config: %v", err) + } + if string(routeData) != "old-route" { + t.Fatalf("expected route rollback, got %s", string(routeData)) + } + certData, err := os.ReadFile(filepath.Join(certDir, "1.crt")) + if err != nil { + t.Fatalf("failed to read cert file: %v", err) + } + if string(certData) != "old-cert" { + t.Fatalf("expected cert rollback, got %s", string(certData)) + } +} + +func TestManagerApplyReturnsWarningWhenRollbackRecoversRuntime(t *testing.T) { + tempDir := t.TempDir() + routePath := filepath.Join(tempDir, "routes.conf") + mainPath := filepath.Join(tempDir, "nginx.conf") + certDir := filepath.Join(tempDir, "certs") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(mainPath, []byte("old-main"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(filepath.Join(certDir, "1.crt"), []byte("old-cert"), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{ + MainConfigPath: mainPath, + RouteConfigPath: routePath, + CertDir: certDir, + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + Executor: &scriptedExecutor{ + reloadErrors: []error{errors.New("target config failed"), nil}, + }, + } + + outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{ + {Path: "1.crt", Content: "new-cert"}, + }) + if outcome.Status != ApplyStatusWarning { + t.Fatalf("expected warning apply outcome, got %#v", outcome) + } + + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if string(mainData) != "old-main" { + t.Fatalf("expected main rollback, got %s", string(mainData)) + } +} + +func TestManagerApplyStartsSafeFallbackWhenNoRollbackConfigExists(t *testing.T) { + tempDir := t.TempDir() + routePath := filepath.Join(tempDir, "routes.conf") + mainPath := filepath.Join(tempDir, "nginx.conf") + executor := &scriptedExecutor{ + testErrors: []error{errors.New("target config failed"), errors.New("rollback config missing"), nil}, + } + manager := &Manager{ + MainConfigPath: mainPath, + RouteConfigPath: routePath, + OpenrestyObservabilityListen: "127.0.0.1:18081", + Executor: executor, + } + + outcome := manager.Apply(context.Background(), "bad-main", "bad-route", nil) + if outcome.Status != ApplyStatusWarning { + t.Fatalf("expected warning apply outcome, got %#v", outcome) + } + if !strings.Contains(outcome.Message, "fallback runtime started") { + t.Fatalf("expected fallback message, got %q", outcome.Message) + } + if executor.testCalls != 3 { + t.Fatalf("expected target, rollback, and fallback tests, got %d", executor.testCalls) + } + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if !strings.Contains(string(mainData), "OpenFlare: No Valid Configuration") { + t.Fatalf("expected safe fallback main config, got %s", string(mainData)) + } + if !strings.Contains(string(mainData), "listen 80 default_server") { + t.Fatalf("expected fallback to listen on port 80, got %s", string(mainData)) + } + if !strings.Contains(string(mainData), "listen 127.0.0.1:18081") { + t.Fatalf("expected fallback to expose local stub_status port, got %s", string(mainData)) + } + if !strings.Contains(string(mainData), "stub_status;") { + t.Fatalf("expected fallback to expose stub_status, got %s", string(mainData)) + } + routeData, err := os.ReadFile(routePath) + if err != nil { + t.Fatalf("failed to read route config: %v", err) + } + if len(routeData) != 0 { + t.Fatalf("expected fallback route config to be empty, got %q", string(routeData)) + } +} + +func TestManagerCertFileTargetPathRejectsEscapes(t *testing.T) { + manager := &Manager{CertDir: filepath.Join(t.TempDir(), "certs")} + if err := os.MkdirAll(manager.CertDir, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + absolutePath := "/tmp/evil.crt" + if runtime.GOOS == "windows" { + absolutePath = `C:/tmp/evil.crt` + } + + testCases := []struct { + path string + shouldErr bool + }{ + {path: "nested/1.crt", shouldErr: false}, + {path: "../escape.crt", shouldErr: true}, + {path: "..\\escape.crt", shouldErr: true}, + {path: absolutePath, shouldErr: true}, + {path: "", shouldErr: true}, + } + + for _, testCase := range testCases { + targetPath, err := manager.certFileTargetPath(testCase.path) + if testCase.shouldErr { + if err == nil { + t.Fatalf("expected path %q to be rejected, got target %q", testCase.path, targetPath) + } + continue + } + if err != nil { + t.Fatalf("expected path %q to be accepted: %v", testCase.path, err) + } + if !strings.HasPrefix(targetPath, manager.CertDir) { + t.Fatalf("expected target path %q to stay under %q", targetPath, manager.CertDir) + } + } +} + +func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) { + tempDir := t.TempDir() + manager := &Manager{ + MainConfigPath: filepath.Join(tempDir, "nginx.conf"), + RouteConfigPath: filepath.Join(tempDir, "routes.conf"), + CertDir: filepath.Join(tempDir, "certs"), + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + Executor: &fakeExecutor{}, + } + + outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{ + {Path: "../escape.crt", Content: "bad"}, + }) + if outcome.Status != ApplyStatusWarning { + t.Fatalf("expected warning apply outcome, got %#v", outcome) + } + + if _, statErr := os.Stat(filepath.Join(tempDir, "escape.crt")); !os.IsNotExist(statErr) { + t.Fatalf("expected escaped file to not exist, stat err = %v", statErr) + } +} + +func TestManagerReconcileWAFIPGroupsRetainsUnchangedDeltaRuntimeFile(t *testing.T) { + manager := &Manager{RuntimeConfigDir: t.TempDir()} + + if err := manager.ReconcileWAFIPGroups([]uint{1}, []protocol.WAFIPGroup{ + {ID: 1, Enabled: true, IPList: []string{"203.0.113.10"}, Checksum: "sum-1"}, + }); err != nil { + t.Fatalf("ReconcileWAFIPGroups failed: %v", err) + } + if err := manager.ReconcileWAFIPGroups([]uint{1, 2}, []protocol.WAFIPGroup{ + {ID: 2, Enabled: true, IPList: []string{"198.51.100.10"}, Checksum: "sum-2"}, + }); err != nil { + t.Fatalf("ReconcileWAFIPGroups second delta failed: %v", err) + } + + checksums, err := manager.WAFIPGroupChecksums() + if err != nil { + t.Fatalf("WAFIPGroupChecksums failed: %v", err) + } + if checksums["1"] != "sum-1" || checksums["2"] != "sum-2" { + t.Fatalf("expected merged checksums, got %#v", checksums) + } + data, err := os.ReadFile(filepath.Join(manager.RuntimeConfigDir, WAFIPGroupsConfigFileName)) + if err != nil { + t.Fatalf("failed to read runtime ip group file: %v", err) + } + text := string(data) + if !strings.Contains(text, "203.0.113.10") || !strings.Contains(text, "198.51.100.10") { + t.Fatalf("expected runtime file to keep both groups, got %s", text) + } +} + +func TestManagerReconcileWAFIPGroupsConvergesToAuthoritativeTarget(t *testing.T) { + runtimeDir := t.TempDir() + manager := &Manager{RuntimeConfigDir: runtimeDir} + initial, err := sharedprotocol.MarshalWAFIPGroupSnapshot(map[string]protocol.WAFIPGroup{ + "1": {ID: 1, Name: "unchanged", Enabled: true, Checksum: "sum-1"}, + "2": {ID: 2, Name: "old", Enabled: true, Checksum: "old-2"}, + "99": {ID: 99, Name: strings.Repeat("x", sharedprotocol.MaxWAFIPGroupSnapshotBytes-1024), Enabled: true, Checksum: "stale"}, + }) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(runtimeDir, WAFIPGroupsConfigFileName), initial, 0o644); err != nil { + t.Fatal(err) + } + + if err = manager.ReconcileWAFIPGroups([]uint{1, 2}, []protocol.WAFIPGroup{{ + ID: 2, Name: "changed", Enabled: true, Checksum: "sum-2", + }}); err != nil { + t.Fatalf("ReconcileWAFIPGroups failed after pruning oversized stale data: %v", err) + } + config, err := manager.readWAFIPGroupsRuntimeConfig() + if err != nil { + t.Fatal(err) + } + if len(config.Groups) != 2 { + t.Fatalf("authoritative group count = %d, want 2: %#v", len(config.Groups), config.Groups) + } + if got := config.Groups["1"].Name; got != "unchanged" { + t.Fatalf("unchanged referenced group was not retained: %q", got) + } + if got := config.Groups["2"].Name; got != "changed" { + t.Fatalf("changed referenced group was not merged: %q", got) + } + if _, exists := config.Groups["99"]; exists { + t.Fatal("historical unreferenced group was not pruned") + } +} + +func TestManagerUpdateExistingWAFIPGroupsIgnoresUnrelatedBroadcast(t *testing.T) { + runtimeDir := t.TempDir() + manager := &Manager{RuntimeConfigDir: runtimeDir} + if err := manager.ReconcileWAFIPGroups([]uint{1}, []protocol.WAFIPGroup{{ID: 1, Name: "old", Checksum: "old"}}); err != nil { + t.Fatal(err) + } + if err := manager.UpdateExistingWAFIPGroups([]protocol.WAFIPGroup{ + {ID: 1, Name: "new", Checksum: "new"}, + {ID: 2, Name: "unrelated", Checksum: "sum-2"}, + }); err != nil { + t.Fatal(err) + } + config, err := manager.readWAFIPGroupsRuntimeConfig() + if err != nil { + t.Fatal(err) + } + if len(config.Groups) != 1 || config.Groups["1"].Name != "new" { + t.Fatalf("broadcast update escaped existing target: %#v", config.Groups) + } +} + +func TestManagerReconcileWAFIPGroupsPublishesRemovalOnlyAndEmptyTargets(t *testing.T) { + runtimeDir := t.TempDir() + manager := &Manager{RuntimeConfigDir: runtimeDir} + if err := manager.ReconcileWAFIPGroups([]uint{1, 2}, []protocol.WAFIPGroup{ + {ID: 1, Checksum: "sum-1"}, {ID: 2, Checksum: "sum-2"}, + }); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(runtimeDir, WAFIPGroupsChecksumFileName)) + if err != nil { + t.Fatal(err) + } + if err = manager.ReconcileWAFIPGroups([]uint{1}, nil); err != nil { + t.Fatalf("removal-only reconcile failed: %v", err) + } + after, err := os.ReadFile(filepath.Join(runtimeDir, WAFIPGroupsChecksumFileName)) + if err != nil { + t.Fatal(err) + } + if string(before) == string(after) { + t.Fatal("removal-only reconcile did not publish a new checksum") + } + if err = manager.ReconcileWAFIPGroups(nil, nil); err != nil { + t.Fatalf("empty authoritative reconcile failed: %v", err) + } + config, err := manager.readWAFIPGroupsRuntimeConfig() + if err != nil { + t.Fatal(err) + } + if len(config.Groups) != 0 { + t.Fatalf("empty authoritative target retained groups: %#v", config.Groups) + } +} + +func TestManagerReconcileWAFIPGroupsRejectsMissingReferencedGroup(t *testing.T) { + runtimeDir := t.TempDir() + data := []byte(`{"groups":{"7":{"id":8,"checksum":"mistaken-match"}}}`) + if err := os.WriteFile(filepath.Join(runtimeDir, WAFIPGroupsConfigFileName), data, 0o644); err != nil { + t.Fatal(err) + } + manager := &Manager{RuntimeConfigDir: runtimeDir} + checksums, err := manager.WAFIPGroupChecksums() + if err != nil { + t.Fatal(err) + } + if _, ok := checksums["7"]; ok { + t.Fatalf("invalid local group was mistakenly reported matched: %#v", checksums) + } + err = manager.ReconcileWAFIPGroups([]uint{7}, nil) + if err == nil || !strings.Contains(err.Error(), "missing referenced WAF IP group 7") { + t.Fatalf("expected clear missing referenced group error, got %v", err) + } +} + +func TestWAFIPGroupChecksumPublishesJSONBeforeSidecar(t *testing.T) { + runtimeDir := t.TempDir() + var writes []string + manager := &Manager{ + RuntimeConfigDir: runtimeDir, + atomicFileWriter: func(path string, data []byte, perm os.FileMode) error { + writes = append(writes, filepath.Base(path)) + return os.WriteFile(path, data, perm) + }, + } + + if err := manager.ReconcileWAFIPGroups([]uint{1}, []protocol.WAFIPGroup{{ + ID: 1, Enabled: true, IPList: []string{"203.0.113.10"}, Checksum: "sum-1", + }}); err != nil { + t.Fatalf("SyncWAFIPGroups failed: %v", err) + } + if got, want := strings.Join(writes, ","), WAFIPGroupsConfigFileName+","+WAFIPGroupsChecksumFileName; got != want { + t.Fatalf("expected JSON then checksum publication, got %s", got) + } + jsonData, err := os.ReadFile(filepath.Join(runtimeDir, WAFIPGroupsConfigFileName)) + if err != nil { + t.Fatalf("read JSON snapshot: %v", err) + } + checksumData, err := os.ReadFile(filepath.Join(runtimeDir, WAFIPGroupsChecksumFileName)) + if err != nil { + t.Fatalf("read checksum sidecar: %v", err) + } + if got, want := strings.TrimSpace(string(checksumData)), checksum(string(jsonData)); got != want { + t.Fatalf("checksum mismatch: got %q want %q", got, want) + } +} + +func TestWAFIPGroupChecksumJSONFailurePreservesOldSidecar(t *testing.T) { + runtimeDir := t.TempDir() + checksumPath := filepath.Join(runtimeDir, WAFIPGroupsChecksumFileName) + if err := os.WriteFile(checksumPath, []byte("old-checksum\n"), 0o644); err != nil { + t.Fatal(err) + } + manager := &Manager{ + RuntimeConfigDir: runtimeDir, + atomicFileWriter: func(path string, _ []byte, _ os.FileMode) error { + if filepath.Base(path) == WAFIPGroupsConfigFileName { + return errors.New("json rename failed") + } + return errors.New("checksum must not be written") + }, + } + + if err := manager.ReconcileWAFIPGroups([]uint{1}, []protocol.WAFIPGroup{{ID: 1, Checksum: "sum-1"}}); err == nil { + t.Fatal("expected JSON publication failure") + } + data, err := os.ReadFile(checksumPath) + if err != nil || string(data) != "old-checksum\n" { + t.Fatalf("old checksum must remain unchanged, data=%q err=%v", data, err) + } +} + +func TestWAFIPGroupChecksumBootstrapsLegacySnapshot(t *testing.T) { + runtimeDir := t.TempDir() + jsonData := []byte(`{"groups":{"7":{"id":7,"enabled":true,"checksum":"sum-7"}}}`) + if err := os.WriteFile(filepath.Join(runtimeDir, WAFIPGroupsConfigFileName), jsonData, 0o644); err != nil { + t.Fatal(err) + } + manager := &Manager{RuntimeConfigDir: runtimeDir} + + checksums, err := manager.WAFIPGroupChecksums() + if err != nil { + t.Fatalf("WAFIPGroupChecksums failed: %v", err) + } + if checksums["7"] != "sum-7" { + t.Fatalf("unexpected group checksums: %#v", checksums) + } + sidecar, err := os.ReadFile(filepath.Join(runtimeDir, WAFIPGroupsChecksumFileName)) + if err != nil { + t.Fatalf("legacy checksum sidecar was not created: %v", err) + } + if got, want := strings.TrimSpace(string(sidecar)), checksum(string(jsonData)); got != want { + t.Fatalf("legacy checksum mismatch: got %q want %q", got, want) + } +} + +func TestWAFIPGroupChecksumRepairsStaleSidecar(t *testing.T) { + runtimeDir := t.TempDir() + jsonData := []byte(`{"groups":{"9":{"id":9,"enabled":true,"checksum":"sum-9"}}}`) + if err := os.WriteFile(filepath.Join(runtimeDir, WAFIPGroupsConfigFileName), jsonData, 0o644); err != nil { + t.Fatal(err) + } + checksumPath := filepath.Join(runtimeDir, WAFIPGroupsChecksumFileName) + if err := os.WriteFile(checksumPath, []byte("stale-checksum\n"), 0o644); err != nil { + t.Fatal(err) + } + manager := &Manager{RuntimeConfigDir: runtimeDir} + + if _, err := manager.WAFIPGroupChecksums(); err != nil { + t.Fatalf("WAFIPGroupChecksums failed: %v", err) + } + sidecar, err := os.ReadFile(checksumPath) + if err != nil { + t.Fatalf("read repaired checksum: %v", err) + } + if got, want := strings.TrimSpace(string(sidecar)), checksum(string(jsonData)); got != want { + t.Fatalf("stale checksum was not repaired: got %q want %q", got, want) + } +} + +func TestWAFIPGroupSnapshotRejectsOversizeBeforePublication(t *testing.T) { + runtimeDir := t.TempDir() + jsonPath := filepath.Join(runtimeDir, WAFIPGroupsConfigFileName) + checksumPath := filepath.Join(runtimeDir, WAFIPGroupsChecksumFileName) + oldJSON := []byte(`{"groups":{"1":{"id":1,"enabled":true,"checksum":"old"}}}`) + oldChecksum := []byte("old-checksum\n") + if err := os.WriteFile(jsonPath, oldJSON, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(checksumPath, oldChecksum, 0o644); err != nil { + t.Fatal(err) + } + manager := &Manager{RuntimeConfigDir: runtimeDir} + + err := manager.ReconcileWAFIPGroups([]uint{1, 2}, []protocol.WAFIPGroup{{ + ID: 2, Name: strings.Repeat("x", sharedprotocol.MaxWAFIPGroupSnapshotBytes), Enabled: true, Checksum: "new", + }}) + if err == nil || !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf("expected clear oversized snapshot error, got %v", err) + } + if got, readErr := os.ReadFile(jsonPath); readErr != nil || string(got) != string(oldJSON) { + t.Fatalf("oversized snapshot touched committed JSON, got=%q err=%v", got, readErr) + } + if got, readErr := os.ReadFile(checksumPath); readErr != nil || string(got) != string(oldChecksum) { + t.Fatalf("oversized snapshot touched committed checksum, got=%q err=%v", got, readErr) + } +} + +func TestObservabilityListenAddress(t *testing.T) { + if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" { + t.Fatalf("unexpected default observability listen address: %s", got) + } + if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" { + t.Fatalf("unexpected path observability listen address: %s", got) + } +} + +func TestEnsureWorldTraversableChainFixesRestrictedParentDirs(t *testing.T) { + tempDir := t.TempDir() + dataDir := filepath.Join(tempDir, "data") + runtimeDir := filepath.Join(dataDir, "etc", "openflare") + if err := os.MkdirAll(runtimeDir, 0o700); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + configPath := filepath.Join(runtimeDir, "waf_config.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + if err := EnsureWorldTraversablePath(runtimeDir); err != nil { + t.Fatalf("EnsureWorldTraversablePath failed: %v", err) + } + + info, err := os.Stat(filepath.Join(dataDir, "etc")) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + if info.Mode().Perm()&0o005 == 0 { + t.Fatalf("expected etc directory to be world-traversable, got %o", info.Mode().Perm()) + } +} diff --git a/backend/openflare/plugins/agent/nginx/mimetypes.go b/backend/openflare/plugins/agent/nginx/mimetypes.go new file mode 100644 index 00000000..cb6f6a03 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/mimetypes.go @@ -0,0 +1,102 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +// DefaultMimeTypes is the embedded nginx mime.types map used by generated configs. +const DefaultMimeTypes = ` +types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/svg+xml svg svgz; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/webp webp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + + application/font-woff woff; + application/java-archive jar war ear; + application/json json; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.apple.mpegurl m3u8; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/vnd.ms-excel xls; + application/vnd.ms-fontobject eot; + application/vnd.ms-powerpoint ppt; + application/vnd.oasis.opendocument.graphics odg; + application/vnd.oasis.opendocument.presentation odp; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.text odt; + application/vnd.openxmlformats-officedocument.presentationml.presentation + pptx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + xlsx; + application/vnd.openxmlformats-officedocument.wordprocessingml.document + docx; + application/vnd.wap.wmlc wmlc; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/xspf+xml xspf; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp2t ts; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} +` diff --git a/backend/openflare/plugins/agent/nginx/observability_assets.go b/backend/openflare/plugins/agent/nginx/observability_assets.go new file mode 100644 index 00000000..6a2d8596 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/observability_assets.go @@ -0,0 +1,67 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import "Wavelet/openflare/plugins/agent/protocol" + +// Local OpenResty observability endpoint (target model): +// GET /openflare/observability returns instantaneous health/connections only. +// Business traffic is collected exclusively from access.log. + +const openRestyObservabilityInitLua = `return +` + +// log.lua no longer accumulates business counters (access.log is the authority). +const openRestyObservabilityLogLua = `return +` + +// read.lua exposes stub_status-style connection gauges as JSON. +const openRestyObservabilityReadLua = `local cjson = require "cjson.safe" + +local function read_stub_status() + local res = ngx.location.capture("/openflare/stub_status") + if not res or res.status ~= 200 or not res.body then + return nil + end + local body = res.body + local active = tonumber(string.match(body, "Active connections:%s*(%d+)")) or 0 + local reading = tonumber(string.match(body, "Reading:%s*(%d+)")) or 0 + local writing = tonumber(string.match(body, "Writing:%s*(%d+)")) or 0 + local waiting = tonumber(string.match(body, "Waiting:%s*(%d+)")) or 0 + return { + active = active, + reading = reading, + writing = writing, + waiting = waiting + } +end + +local connections = read_stub_status() +local payload = { + ok = connections ~= nil, + captured_at_unix = ngx.time(), + connections = connections or { + active = 0, + reading = 0, + writing = 0, + waiting = 0 + } +} + +ngx.header.content_type = "application/json" +ngx.status = ngx.HTTP_OK +ngx.say(cjson.encode(payload)) +` + +// ManagedObservabilityLuaFiles returns embedded Lua assets for OpenResty observability. +func ManagedObservabilityLuaFiles() []protocol.SupportFile { + return []protocol.SupportFile{ + {Path: "init.lua", Content: openRestyObservabilityInitLua}, + {Path: "log.lua", Content: openRestyObservabilityLogLua}, + {Path: "read.lua", Content: openRestyObservabilityReadLua}, + {Path: "observability/init.lua", Content: openRestyObservabilityInitLua}, + {Path: "observability/log.lua", Content: openRestyObservabilityLogLua}, + {Path: "observability/read.lua", Content: openRestyObservabilityReadLua}, + } +} diff --git a/backend/openflare/plugins/agent/nginx/observability_assets_test.go b/backend/openflare/plugins/agent/nginx/observability_assets_test.go new file mode 100644 index 00000000..b74646d9 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/observability_assets_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "strings" + "testing" +) + +func TestManagedObservabilityLuaIsHealthOnly(t *testing.T) { + t.Parallel() + + files := ManagedObservabilityLuaFiles() + var logLua, readLua string + for _, file := range files { + switch file.Path { + case "log.lua": + logLua = file.Content + case "read.lua": + readLua = file.Content + } + } + if logLua == "" || readLua == "" { + t.Fatal("expected log.lua and read.lua") + } + // Business counters must not be written in log phase. + if strings.Contains(logLua, "openresty_rx_bytes") || + strings.Contains(logLua, "request_count") { + t.Fatal("log.lua must not accumulate business counters") + } + if !strings.Contains(readLua, "connections") || !strings.Contains(readLua, "ok") { + t.Fatal("read.lua must expose ok + connections health snapshot") + } + if strings.Contains(readLua, "top_domains") || strings.Contains(readLua, "request_count") { + t.Fatal("read.lua must not expose business traffic aggregates") + } +} diff --git a/backend/openflare/plugins/agent/nginx/pow_assets.go b/backend/openflare/plugins/agent/nginx/pow_assets.go new file mode 100644 index 00000000..7a626e07 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_assets.go @@ -0,0 +1,694 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "embed" + "path/filepath" + "strings" + + "Wavelet/openflare/plugins/agent/protocol" +) + +//go:embed pow_static +var powStaticFS embed.FS + +const openRestyPowRuntimeLua = `local _M = {} + +local source = debug.getinfo(1, "S").source or "" +if string.sub(source, 1, 1) == "@" then + local script_path = string.sub(source, 2) + local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$") + if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then + package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path + end +end + +local policy = require "pow.policy" +local pow_sessions = ngx.shared.openflare_pow_sessions +local pow_config_dict = ngx.shared.openflare_pow_config +local cjson = require "cjson.safe" + +local function session_cookie(value, ttl) + local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl) + if ngx.var.scheme == "https" then cookie = cookie .. "; Secure" end + return cookie +end + +-- evaluate is called by a DAG pow node. true continues along its next edge; +-- false means the challenge flow has taken ownership of the request. +function _M.evaluate(config) + config = config or {} + ngx.ctx.openflare_pow_config = config + + local host = ngx.var.host + if not host or host == "" then return true end + local session_ttl = config.session_ttl or 600 + local uri = ngx.var.uri or "" + local ua = ngx.var.http_user_agent or "" + local remote_ip = ngx.var.remote_addr or "" + + if policy.match_any(remote_ip, ua, uri, config.whitelist or {}) then return true end + local blacklist = config.blacklist or {} + if policy.has_entries(blacklist) and not policy.match_any(remote_ip, ua, uri, blacklist) then return true end + + local cookie_val = ngx.var["cookie___openflare_pow"] + if cookie_val and cookie_val ~= "" then + local session_key = host .. ":" .. cookie_val + if pow_sessions:get(session_key) then + pow_sessions:set(session_key, "1", session_ttl) + ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl) + return true + end + end + + local api_prefix = "/.within.website/x/cmd/anubis/api/" + local static_prefix = "/.within.website/x/cmd/anubis/static/" + if string.sub(uri, 1, #api_prefix) == api_prefix or string.sub(uri, 1, #static_prefix) == static_prefix then + return false + end + + local config_key = "_request_config:" .. (ngx.var.request_id or ngx.md5(host .. uri .. tostring(ngx.now()))) + pow_config_dict:set(config_key, cjson.encode(config), config.challenge_ttl or 300) + local challenge_args = { + redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""), + host = host, + openflare_pow_config_key = config_key, + } + ngx.req.set_uri_args(challenge_args) + ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge", challenge_args) + return false +end + +-- Compatibility entrypoint for old rendered routes. PoW selection now belongs +-- exclusively to WAF graph nodes, so this function intentionally does nothing. +function _M.check() + return true +end + +return _M +` + +/* Removed legacy request-time configuration scanner. Graph execution now calls +evaluate(config) with the reached node. +local source = debug.getinfo(1, "S").source or "" +if string.sub(source, 1, 1) == "@" then + local script_path = string.sub(source, 2) + local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$") + if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then + package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path + end +end + +local cjson = require "cjson.safe" +local policy = require "pow.policy" + +local pow_config_dict = ngx.shared.openflare_pow_config +local pow_sessions = ngx.shared.openflare_pow_sessions + +local function session_cookie(value, ttl) + local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl) + if ngx.var.scheme == "https" then + cookie = cookie .. "; Secure" + end + return cookie +end + +-- Lazy-load pow_config from file; reload when content changes +local function load_pow_config() + local config_paths = { + "__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_config.json", + "/etc/nginx/openflare-lua/waf_config.json", + "/usr/local/openresty/nginx/conf/waf_config.json" + } + for _, config_path in ipairs(config_paths) do + local f = io.open(config_path, "r") + if f then + local content = f:read("*a") + f:close() + local current_hash = ngx.md5(content or "") + + if current_hash == pow_config_dict:get("_config_hash") then + return + end + + -- Clear old domain/site entries + local old_keys = pow_config_dict:get("_domain_keys") + if old_keys then + for domain in string.gmatch(old_keys, "[^\n]+") do + pow_config_dict:delete(domain) + end + end + + local domain_keys = {} + if content and content ~= "" and content ~= "{}" then + local ok, decoded = pcall(cjson.decode, content) + if ok and decoded and decoded.rule_groups and decoded.site_rule_groups then + -- Build rule groups map (group ID -> PoWConfig) + local groups = {} + for _, group in ipairs(decoded.rule_groups) do + if group.pow_enabled then + groups[tostring(group.id)] = group.pow_config or {} + end + end + -- Build site name to pow_config map + for site, group_ids in pairs(decoded.site_rule_groups) do + local pow_config = nil + -- Check custom group IDs first + for _, id in ipairs(group_ids) do + pow_config = groups[tostring(id)] + if pow_config then + break + end + end + -- If not found, check global group IDs + if not pow_config then + for _, group in ipairs(decoded.rule_groups) do + if group.is_global and group.pow_enabled then + pow_config = group.pow_config or {} + break + end + end + end + if pow_config ~= nil then + pow_config_dict:set(site, cjson.encode({enabled = true, config = pow_config}), 0) + domain_keys[#domain_keys+1] = site + end + end + end + end + + pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0) + pow_config_dict:set("_config_hash", current_hash, 0) + return true + end + end + + if pow_config_dict:add("_pow_unreadable_config_logged", true, 60) then + ngx.log(ngx.WARN, "openflare pow config is not readable by worker; check directory permissions under ", "__OPENFLARE_RUNTIME_CONFIG_DIR__") + end + return false +end + +load_pow_config() + +local host = ngx.var.host +if not host or host == "" then + return +end + +local site = ngx.var.openflare_waf_site or "" +if site == "" then + return +end + +local config_raw = pow_config_dict:get(site) +if not config_raw then + return +end + +local ok, route_config = pcall(cjson.decode, config_raw) +if not ok or not route_config then + return +end + +if not route_config.enabled then + return +end + +local config = route_config.config or {} +local session_ttl = config.session_ttl or 600 +local uri = ngx.var.uri or "" +local ua = ngx.var.http_user_agent or "" +local remote_ip = ngx.var.remote_addr or "" + +-- Check whitelist: if matched, skip PoW +local whitelist = config.whitelist or {} +if policy.match_any(remote_ip, ua, uri, whitelist) then + return +end + +-- Check blacklist: if matched, require PoW +local blacklist = config.blacklist or {} +local has_blacklist = policy.has_entries(blacklist) +local need_pow = false +if has_blacklist then + need_pow = policy.match_any(remote_ip, ua, uri, blacklist) +else + -- No blacklist means all non-whitelisted need PoW + need_pow = true +end + +if not need_pow then + return +end + +-- Check valid session cookie +local cookie_val = ngx.var["cookie___openflare_pow"] +if cookie_val and cookie_val ~= "" then + local session_key = host .. ":" .. cookie_val + local session_data = pow_sessions:get(session_key) + if session_data then + pow_sessions:set(session_key, "1", session_ttl) + ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl) + return + end +end + +-- If requesting the challenge API endpoints, let them through (handled by content_by_lua) +local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/" +local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/" +if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then + return +end +if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then + return +end + +-- Render the challenge page through an internal redirect so the browser stays +-- on the originally requested URL instead of seeing a 302 hop. +ngx.req.set_uri_args({ + redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""), + host = host +}) +return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge") +end + +return _M +*/ + +const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or "" +if string.sub(source, 1, 1) == "@" then + local script_path = string.sub(source, 2) + local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$") + if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then + package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path + end +end + +return require("pow.runtime").check() +` + +const openRestyPowChallengeLua = `local cjson = require "cjson.safe" + +local pow_challenges = ngx.shared.openflare_pow_challenges +local pow_config_dict = ngx.shared.openflare_pow_config + +local function generate_entropy() + local pieces = { + tostring(ngx.now()), + tostring(ngx.worker.pid()), + tostring(math.random()), + ngx.var.remote_addr or "", + ngx.var.http_user_agent or "", + ngx.var.request_id or "", + } + return table.concat(pieces, ":") +end + +local args = ngx.req.get_uri_args() +local host = args["host"] or ngx.var.host or "" +local redir = args["redir"] or "" + +local config = ngx.ctx.openflare_pow_config +local config_key = args["openflare_pow_config_key"] or "" +if type(config) ~= "table" and config_key ~= "" then + local config_raw = pow_config_dict:get(config_key) + if config_raw then + config = cjson.decode(config_raw) + end +end +if config_key ~= "" then pow_config_dict:delete(config_key) end +if type(config) ~= "table" then + ngx.status = 403 + ngx.say("PoW graph node was not evaluated for this request") + return +end +local difficulty = config.difficulty or 4 +local algorithm = config.algorithm or "fast" +local challenge_ttl = config.challenge_ttl or 300 +local session_ttl = config.session_ttl or 600 + +-- Generate challenge data without depending on ngx.random_bytes, which is not +-- available in every OpenResty runtime build. +local entropy = generate_entropy() +local challenge_id = ngx.md5(entropy .. ":id") +local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b") + +-- Store challenge +local challenge_info = cjson.encode({ + data = challenge_data, + difficulty = difficulty, + host = host, + redir = redir, + session_ttl = session_ttl +}) +pow_challenges:set(challenge_id, challenge_info, challenge_ttl) + +local static_prefix = "/.within.website/x/cmd/anubis/static/" +local accept_lang = ngx.var.http_accept_language or "" +local lang = "en" +if string.find(accept_lang, "zh") then + lang = "zh-CN" +end + +local t_title = "Making sure you're not a bot!" +local t_status = "Loading..." +local t_protected = "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown." +local t_why = "Why am I seeing this?" +local t_why_desc = "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically." +local t_noscript = "JavaScript is required to pass this verification. Please enable JavaScript and reload." + +if lang == "zh-CN" then + t_title = "正在确认你是不是机器人!" + t_status = "加载中..." + t_protected = "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。" + t_why = "为什么我会看到这个?" + t_why_desc = "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。" + t_noscript = "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。" +end + +ngx.header.content_type = "text/html; charset=utf-8" +ngx.say([[ + + + + + +]] .. t_title .. [[ + + + + + + + + +
+

]] .. t_title .. [[

+
+ +

]] .. t_status .. [[

+

]] .. t_protected .. [[

+
+
+]] .. t_why .. [[ +

]] .. t_why_desc .. [[

+
+ +
+
+ + +]]) +` + +const openRestyPowVerifyLua = `local cjson = require "cjson.safe" + +local pow_challenges = ngx.shared.openflare_pow_challenges +local pow_sessions = ngx.shared.openflare_pow_sessions + +local site = ngx.var.openflare_waf_site or "" +if site == "" then + ngx.status = 403 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({error = "PoW site not resolved; openflare_waf_site is required"})) + return +end + +local args = ngx.req.get_uri_args() +local challenge_id = args["id"] or "" +local response = args["response"] or "" +local nonce_str = args["nonce"] or "" +local redir = args["redir"] or "" +local elapsed = args["elapsedTime"] or "" + +if challenge_id == "" or response == "" or nonce_str == "" then + ngx.status = 400 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({error = "missing parameters"})) + return +end + +local nonce = tonumber(nonce_str) +if not nonce then + ngx.status = 400 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({error = "invalid nonce"})) + return +end + +-- Get stored challenge +local challenge_raw = pow_challenges:get(challenge_id) +if not challenge_raw then + ngx.status = 410 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({error = "challenge expired or not found"})) + return +end + +local ok, challenge_info = pcall(cjson.decode, challenge_raw) +if not ok or not challenge_info then + ngx.status = 500 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({error = "invalid challenge data"})) + return +end + +local challenge_data = challenge_info.data or "" +local difficulty = challenge_info.difficulty or 4 +local host = challenge_info.host or ngx.var.host or "" +local session_ttl = challenge_info.session_ttl or 600 + +-- Compute SHA-256(challenge_data + nonce) +local calc_string = challenge_data .. tostring(math.floor(nonce)) +local calculated = ngx.sha1_bin ~= nil and "" or "" + +-- Use resty.sha256 for proper SHA-256 +local sha256 = require "resty.sha256" +local str = require "resty.string" +local hasher = sha256:new() +hasher:update(calc_string) +local hash_bytes = hasher:final() +local hash_hex = str.to_hex(hash_bytes) + +-- Verify hash matches response +if hash_hex ~= string.lower(response) then + ngx.status = 403 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({error = "hash mismatch"})) + return +end + +-- Verify difficulty (leading zeros in hex) +local prefix = string.rep("0", difficulty) +if string.sub(hash_hex, 1, difficulty) ~= prefix then + ngx.status = 403 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({error = "insufficient difficulty"})) + return +end + +-- Invalidate challenge (prevent replay) +pow_challenges:delete(challenge_id) + +-- Generate session token +local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid()))) + +-- Store session +pow_sessions:set(host .. ":" .. session_token, "1", session_ttl) + +-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when +-- the current request itself is HTTPS. +local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl) +if ngx.var.scheme == "https" then + cookie = cookie .. "; Secure" +end +ngx.header["Set-Cookie"] = cookie + +if redir ~= "" then + return ngx.redirect(redir) +end + +ngx.header.content_type = "application/json" +ngx.say(cjson.encode({ok = true})) +` + +const openRestyPowPolicyLua = `local M = {} + +local function match_ip(remote_ip, ips) + if not ips or #ips == 0 then return false end + for _, ip in ipairs(ips) do + if ip == remote_ip then + return true + end + end + return false +end + +local function match_cidr(remote_ip, cidrs) + if not cidrs or #cidrs == 0 then return false end + for _, cidr in ipairs(cidrs) do + local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$") + if m then + local mask_bits = tonumber(m[2]) + if mask_bits and mask_bits >= 0 and mask_bits <= 32 then + local function ip_to_num(ip_str) + local parts = {} + for part in string.gmatch(ip_str, "%d+") do + parts[#parts+1] = tonumber(part) or 0 + end + if #parts ~= 4 then return 0 end + return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4] + end + local remote_num = ip_to_num(remote_ip) + local net_num = ip_to_num(m[1]) + if mask_bits == 0 then + return true + end + local mask = math.floor(2^(32 - mask_bits)) + mask = 4294967296 - mask + if bit.band(remote_num, mask) == bit.band(net_num, mask) then + return true + end + end + end + end + return false +end + +local function match_path(uri, patterns) + if not patterns or #patterns == 0 then return false end + for _, pattern in ipairs(patterns) do + local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c) + if c == "*" then return ".*" end + return "%" .. c + end) .. "$", "i") + if ok and match then + return true + end + end + return false +end + +local function match_path_regex(uri, patterns) + if not patterns or #patterns == 0 then return false end + for _, pattern in ipairs(patterns) do + local ok, match = pcall(ngx.re.match, uri, pattern) + if ok and match then + return true + end + end + return false +end + +local function match_ua(ua, patterns) + if not patterns or #patterns == 0 then return false end + for _, pattern in ipairs(patterns) do + if ua and string.find(ua, pattern, 1, true) then + return true + end + end + return false +end + +function M.match_any(remote_ip, ua, uri, list) + if not list then return false end + if match_ip(remote_ip, list.ips) then return true end + if match_cidr(remote_ip, list.ip_cidrs) then return true end + if match_path(uri, list.paths) then return true end + if match_path_regex(uri, list.path_regexes) then return true end + if match_ua(ua, list.user_agents) then return true end + return false +end + +function M.has_entries(list) + if not list then return false end + return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0 +end + +return M +` + +// ManagedPowLuaFiles returns embedded Lua assets for proof-of-work challenges. +func ManagedPowLuaFiles() []protocol.SupportFile { + return []protocol.SupportFile{ + {Path: "pow/runtime.lua", Content: openRestyPowRuntimeLua}, + {Path: "pow/check.lua", Content: openRestyPowCheckLua}, + {Path: "pow/challenge.lua", Content: openRestyPowChallengeLua}, + {Path: "pow/verify.lua", Content: openRestyPowVerifyLua}, + {Path: "pow/policy.lua", Content: openRestyPowPolicyLua}, + } +} + +// ManagedPowStaticFiles returns embedded static assets served by the PoW module. +func ManagedPowStaticFiles() ([]protocol.SupportFile, error) { + var files []protocol.SupportFile + entries, err := powStaticFS.ReadDir("pow_static") + if err != nil { + return nil, err + } + var walk func(dir string) error + walk = func(dir string) error { + entries, err := powStaticFS.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + fullPath := filepath.Join(dir, entry.Name()) + if entry.IsDir() { + if err := walk(fullPath); err != nil { + return err + } + continue + } + data, err := powStaticFS.ReadFile(fullPath) + if err != nil { + return err + } + // Convert pow_static/css/xess.css -> pow/static/css/xess.css + relPath := strings.TrimPrefix(fullPath, "pow_static/") + files = append(files, protocol.SupportFile{ + Path: "pow/static/" + relPath, + Content: string(data), + }) + } + return nil + } + for _, entry := range entries { + fullPath := filepath.Join("pow_static", entry.Name()) + if entry.IsDir() { + if err := walk(fullPath); err != nil { + return nil, err + } + } else { + data, err := powStaticFS.ReadFile(fullPath) + if err != nil { + return nil, err + } + relPath := strings.TrimPrefix(fullPath, "pow_static/") + files = append(files, protocol.SupportFile{ + Path: "pow/static/" + relPath, + Content: string(data), + }) + } + } + return files, nil +} diff --git a/backend/openflare/plugins/agent/nginx/pow_assets_test.go b/backend/openflare/plugins/agent/nginx/pow_assets_test.go new file mode 100644 index 00000000..bf457808 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_assets_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "testing" + + lua "github.com/yuin/gopher-lua" +) + +func TestPowRuntimePassesConfigKeyToInternalChallenge(t *testing.T) { + state := lua.NewState() + defer state.Close() + + if err := state.DoString(` +package.preload["pow.policy"] = function() + return { + match_any = function() return false end, + has_entries = function() return false end, + } +end +package.preload["cjson.safe"] = function() + return { encode = function() return "{}" end } +end + +local config_values = {} +local config_dict = {} +function config_dict:set(key, value) config_values[key] = value return true end +function config_dict:get(key) return config_values[key] end + +local sessions = {} +function sessions:get() return nil end +function sessions:set() return true end + +ngx = { + var = { + host = "pow.example.com", + uri = "/protected", + scheme = "https", + remote_addr = "192.0.2.1", + http_user_agent = "test", + request_id = "request-1", + }, + ctx = {}, + header = {}, + shared = { + openflare_pow_sessions = sessions, + openflare_pow_config = config_dict, + }, + req = {}, + now = function() return 1 end, +} +function ngx.req.set_uri_args(args) captured_uri_args = args end +function ngx.exec(uri, args) + captured_exec_uri = uri + captured_exec_args = args +end +`); err != nil { + t.Fatalf("prepare Lua runtime: %v", err) + } + + chunk, err := state.LoadString(openRestyPowRuntimeLua) + if err != nil { + t.Fatalf("load PoW runtime: %v", err) + } + if err := state.CallByParam(lua.P{Fn: chunk, NRet: 1, Protect: true}); err != nil { + t.Fatalf("initialize PoW runtime: %v", err) + } + runtimeModule := state.Get(-1) + state.Pop(1) + + evaluate := state.GetField(runtimeModule, "evaluate") + config := state.NewTable() + config.RawSetString("challenge_ttl", lua.LNumber(300)) + if err := state.CallByParam(lua.P{Fn: evaluate, NRet: 1, Protect: true}, config); err != nil { + t.Fatalf("evaluate PoW node: %v", err) + } + state.Pop(1) + + if got := state.GetGlobal("captured_exec_uri").String(); got != "/.within.website/x/cmd/anubis/api/make-challenge" { + t.Fatalf("unexpected internal challenge URI: %q", got) + } + execArgs, ok := state.GetGlobal("captured_exec_args").(*lua.LTable) + if !ok { + t.Fatal("expected ngx.exec to receive explicit challenge arguments") + } + if got := execArgs.RawGetString("openflare_pow_config_key").String(); got != "_request_config:request-1" { + t.Fatalf("unexpected PoW config key: %q", got) + } + if state.GetGlobal("captured_uri_args") != execArgs { + t.Fatal("expected URI arguments and internal redirect arguments to use the same table") + } +} diff --git a/backend/openflare/plugins/agent/nginx/pow_static/css/static/geist.woff2 b/backend/openflare/plugins/agent/nginx/pow_static/css/static/geist.woff2 new file mode 100644 index 00000000..6fd61c44 Binary files /dev/null and b/backend/openflare/plugins/agent/nginx/pow_static/css/static/geist.woff2 differ diff --git a/backend/openflare/plugins/agent/nginx/pow_static/css/static/iosevka-curly.woff2 b/backend/openflare/plugins/agent/nginx/pow_static/css/static/iosevka-curly.woff2 new file mode 100644 index 00000000..df9df40c Binary files /dev/null and b/backend/openflare/plugins/agent/nginx/pow_static/css/static/iosevka-curly.woff2 differ diff --git a/backend/openflare/plugins/agent/nginx/pow_static/css/static/podkova.css b/backend/openflare/plugins/agent/nginx/pow_static/css/static/podkova.css new file mode 100644 index 00000000..efa1fd7a --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/css/static/podkova.css @@ -0,0 +1,7 @@ +@font-face { + font-family: "Podkova"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("podkova.woff2") format("woff2"); +} diff --git a/backend/openflare/plugins/agent/nginx/pow_static/css/static/podkova.woff2 b/backend/openflare/plugins/agent/nginx/pow_static/css/static/podkova.woff2 new file mode 100644 index 00000000..508f97d3 Binary files /dev/null and b/backend/openflare/plugins/agent/nginx/pow_static/css/static/podkova.woff2 differ diff --git a/backend/openflare/plugins/agent/nginx/pow_static/css/xess.css b/backend/openflare/plugins/agent/nginx/pow_static/css/xess.css new file mode 100644 index 00000000..67a0c4ba --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/css/xess.css @@ -0,0 +1,149 @@ +:root { + --body-sans-font: Geist, sans-serif; + --body-preformatted-font: Iosevka Curly Iaso, monospace; + --body-title-font: Podkova, serif; + + --background: #1d2021; + --text: #f9f5d7; + --text-selection: #d3869b; + --preformatted-background: #3c3836; + --link-foreground: #b16286; + --link-background: #282828; + --blockquote-border-left: 1px solid #bdae93; + + --progress-bar-outline: #b16286 solid 4px; + --progress-bar-fill: #b16286; +} +@media (prefers-color-scheme: light) { + :root { + --background: #f9f5d7; + --text: #1d2021; + --text-selection: #d3869b; + --preformatted-background: #ebdbb2; + --link-foreground: #b16286; + --link-background: #fbf1c7; + --blockquote-border-left: 1px solid #655c54; + } +} + +@font-face { + font-family: "Geist"; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url("./static/geist.woff2") format("woff2"); +} + +@font-face { + font-family: "Podkova"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./static/podkova.woff2") format("woff2"); +} + +@font-face { + font-family: "Iosevka Curly"; + font-style: monospace; + font-display: swap; + src: url("./static/iosevka-curly.woff2") format("woff2"); +} + +main { + font-family: var(--body-sans-font); + max-width: 50rem; + padding: 2rem; + margin: auto; +} + +::selection { + background: var(--text-selection); +} + +body { + background: var(--background); + color: var(--text); +} + +body, +html { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-left: auto; + margin-right: auto; +} + +.centered-div { + text-align: center; +} + +#status { + font-variant-numeric: tabular-nums; +} + +.centered-div { + text-align: center; +} + +#status { + font-variant-numeric: tabular-nums; +} + +#progress { + display: none; + width: min(20rem, 90%); + height: 2rem; + border-radius: 1rem; + overflow: hidden; + margin: 1rem 0 2rem; + outline-offset: 2px; + outline: var(--progress-bar-outline); +} + +.bar-inner { + background-color: var(--progress-bar-fill); + height: 100%; + width: 0; + transition: width 0.25s ease-in; +} + +@media (prefers-reduced-motion: no-preference) { + .bar-inner { + transition: width 0.25s ease-in; + } +} + +pre { + background-color: var(--preformatted-background); + padding: 1em; + border: 0; + font-family: var(--body-preformatted-font); +} + +a, +a:active, +a:visited { + color: var(--link-foreground); + background-color: var(--link-background); +} + +h1, +h2, +h3, +h4, +h5 { + margin-bottom: 0.1rem; + font-family: var(--body-title-font); +} + +blockquote { + border-left: var(--blockquote-border-left); + margin: 0.5em 10px; + padding: 0.5em 10px; +} + +footer { + text-align: center; +} diff --git a/backend/openflare/plugins/agent/nginx/pow_static/img/happy.webp b/backend/openflare/plugins/agent/nginx/pow_static/img/happy.webp new file mode 100644 index 00000000..40b979da Binary files /dev/null and b/backend/openflare/plugins/agent/nginx/pow_static/img/happy.webp differ diff --git a/backend/openflare/plugins/agent/nginx/pow_static/img/pensive.webp b/backend/openflare/plugins/agent/nginx/pow_static/img/pensive.webp new file mode 100644 index 00000000..e26faefe Binary files /dev/null and b/backend/openflare/plugins/agent/nginx/pow_static/img/pensive.webp differ diff --git a/backend/openflare/plugins/agent/nginx/pow_static/img/reject.webp b/backend/openflare/plugins/agent/nginx/pow_static/img/reject.webp new file mode 100644 index 00000000..17bc9398 Binary files /dev/null and b/backend/openflare/plugins/agent/nginx/pow_static/img/reject.webp differ diff --git a/backend/openflare/plugins/agent/nginx/pow_static/js/algorithms/index.mjs b/backend/openflare/plugins/agent/nginx/pow_static/js/algorithms/index.mjs new file mode 100644 index 00000000..24a47324 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/js/algorithms/index.mjs @@ -0,0 +1,32 @@ +/* +@licstart The following is the entire license notice for the +JavaScript code in this page. + +Copyright (c) 2025 Xe Iaso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is +used under the terms of the Apache 2 license. + +@licend The above is the entire license notice +for the JavaScript code in this page. +*/ +(()=>{var k=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function n(c,b,w=5,e=null,g,u=Math.trunc(Math.max(k()/2,1))){console.debug("fast algo");let s="purejs";return window.isSecureContext&&(s="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),s="purejs"),new Promise((p,l)=>{let m=`${c.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${s}.mjs?cacheBuster=${c.version}`,f=[],d=!1,a=()=>{console.log("PoW aborted"),i(),l(new DOMException("Aborted","AbortError"))},i=()=>{d||(d=!0,f.forEach(r=>r.terminate()),e?.removeEventListener("abort",a))};if(e!=null){if(e.aborted)return a();e.addEventListener("abort",a,{once:!0})}for(let r=0;r{typeof o.data=="number"?g?.(o.data):(i(),p(o.data))},t.onerror=o=>{i(),l(o)},t.postMessage({data:b,difficulty:w,nonce:r,threads:u}),f.push(t)}})}var P={fast:n,slow:n};})(); +//# sourceMappingURL=index.mjs.map diff --git a/backend/openflare/plugins/agent/nginx/pow_static/js/main.mjs b/backend/openflare/plugins/agent/nginx/pow_static/js/main.mjs new file mode 100644 index 00000000..d3bc1c29 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/js/main.mjs @@ -0,0 +1,32 @@ +/* +@licstart The following is the entire license notice for the +JavaScript code in this page. + +Copyright (c) 2025 Xe Iaso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is +used under the terms of the Apache 2 license. + +@licend The above is the entire license notice +for the JavaScript code in this page. +*/ +(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null&&e&&window.location.href.startsWith(e)){let t=new URLSearchParams(window.location.search).get("redir");if(t){try{let u=new URL(t,window.location.href);if(u.protocol==="http:"||u.protocol==="https:")return t}catch(s){}}return window.location.href}return window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.textContent=l,n.textContent=f,s.src=w,i.style.display="none"};n.textContent=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.textContent=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.textContent=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})(); +//# sourceMappingURL=main.mjs.map diff --git a/backend/openflare/plugins/agent/nginx/pow_static/js/worker/sha256-purejs.mjs b/backend/openflare/plugins/agent/nginx/pow_static/js/worker/sha256-purejs.mjs new file mode 100644 index 00000000..e2c2d196 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/js/worker/sha256-purejs.mjs @@ -0,0 +1,32 @@ +/* +@licstart The following is the entire license notice for the +JavaScript code in this page. + +Copyright (c) 2025 Xe Iaso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is +used under the terms of the Apache 2 license. + +@licend The above is the entire license notice +for the JavaScript code in this page. +*/ +(()=>{function _(o,i,e,n){function t(r){return r instanceof e?r:new e(function(a){a(r)})}return new(e||(e=Promise))(function(r,a){function p(c){try{f(n.next(c))}catch(l){a(l)}}function u(c){try{f(n.throw(c))}catch(l){a(l)}}function f(c){c.done?r(c.value):t(c.value).then(p,u)}f((n=n.apply(o,i||[])).next())})}function v(o,i){var e={label:0,sent:function(){if(r[0]&1)throw r[1];return r[1]},trys:[],ops:[]},n,t,r,a=Object.create((typeof Iterator=="function"?Iterator:Object).prototype);return a.next=p(0),a.throw=p(1),a.return=p(2),typeof Symbol=="function"&&(a[Symbol.iterator]=function(){return this}),a;function p(f){return function(c){return u([f,c])}}function u(f){if(n)throw new TypeError("Generator is already executing.");for(;a&&(a=0,f[0]&&(e=0)),e;)try{if(n=1,t&&(r=f[0]&2?t.return:f[0]?t.throw||((r=t.return)&&r.call(t),0):t.next)&&!(r=r.call(t,f[1])).done)return r;switch(t=0,r&&(f=[f[0]&2,r.value]),f[0]){case 0:case 1:r=f;break;case 4:return e.label++,{value:f[1],done:!1};case 5:e.label++,t=f[1],f=[0];continue;case 7:f=e.ops.pop(),e.trys.pop();continue;default:if(r=e.trys,!(r=r.length>0&&r[r.length-1])&&(f[0]===6||f[0]===2)){e=0;continue}if(f[0]===3&&(!r||f[1]>r[0]&&f[1]S)throw new Error("Cannot hash more than 2^53 - 1 bits");for(;n>0;)this.buffer[this.bufferLength++]=i[e++],n--,this.bufferLength===h&&(this.hashBuffer(),this.bufferLength=0)},o.prototype.digest=function(){if(!this.finished){var i=this.bytesHashed*8,e=new DataView(this.buffer.buffer,this.buffer.byteOffset,this.buffer.byteLength),n=this.bufferLength;if(e.setUint8(this.bufferLength++,128),n%h>=h-8){for(var t=this.bufferLength;t>>24&255,r[t*4+1]=this.state[t]>>>16&255,r[t*4+2]=this.state[t]>>>8&255,r[t*4+3]=this.state[t]>>>0&255;return r},o.prototype.hashBuffer=function(){for(var i=this,e=i.buffer,n=i.state,t=n[0],r=n[1],a=n[2],p=n[3],u=n[4],f=n[5],c=n[6],l=n[7],s=0;s>>17|y<<15)^(y>>>19|y<<13)^y>>>10;y=this.temp[s-15];var T=(y>>>7|y<<25)^(y>>>18|y<<14)^y>>>3;this.temp[s]=(A+this.temp[s-7]|0)+(T+this.temp[s-16]|0)}var m=(((u>>>6|u<<26)^(u>>>11|u<<21)^(u>>>25|u<<7))+(u&f^~u&c)|0)+(l+(E[s]+this.temp[s]|0)|0)|0,P=((t>>>2|t<<30)^(t>>>13|t<<19)^(t>>>22|t<<10))+(t&r^t&a^r&a)|0;l=c,c=f,f=u,u=p+m|0,p=a,a=r,r=t,t=m+P|0}n[0]+=t,n[1]+=r,n[2]+=a,n[3]+=p,n[4]+=u,n[5]+=f,n[6]+=c,n[7]+=l},o})();var d=o=>new TextEncoder().encode(o);var L=typeof Buffer<"u"&&Buffer.from?function(o){return Buffer.from(o,"utf8")}:d;function x(o){return o instanceof Uint8Array?o:typeof o=="string"?L(o):ArrayBuffer.isView(o)?new Uint8Array(o.buffer,o.byteOffset,o.byteLength/Uint8Array.BYTES_PER_ELEMENT):new Uint8Array(o)}function w(o){return typeof o=="string"?o.length===0:o.byteLength===0}var j=(function(){function o(i){this.secret=i,this.hash=new b,this.reset()}return o.prototype.update=function(i){if(!(w(i)||this.error))try{this.hash.update(x(i))}catch(e){this.error=e}},o.prototype.digestSync=function(){if(this.error)throw this.error;return this.outer?(this.outer.finished||this.outer.update(this.hash.digest()),this.outer.digest()):this.hash.digest()},o.prototype.digest=function(){return _(this,void 0,void 0,function(){return v(this,function(i){return[2,this.digestSync()]})})},o.prototype.reset=function(){if(this.hash=new b,this.secret){this.outer=new b;var i=U(this.secret),e=new Uint8Array(h);e.set(i);for(var n=0;nh){var e=new b;e.update(i),i=e.digest()}var n=new Uint8Array(h);return n.set(i),n}var B=o=>{let i=new j;return i.update(o),i.digest()};function I(o){return Array.from(o).map(i=>i.toString(16).padStart(2,"0")).join("")}addEventListener("message",async({data:o})=>{let{data:i,difficulty:e,threads:n}=o,t=o.nonce,r=t===0,a=0,p=Math.floor(e/2),u=e%2!==0;for(;;){let f=await B(i+t),c=new Uint8Array(f),l=!0;for(let s=0;s>4!==0&&(l=!1),l){let s=I(c);postMessage({hash:s,data:i,difficulty:e,nonce:t});return}t+=n,a++,t%1!==0&&(t=Math.trunc(t)),r&&(a&1023)===0&&postMessage(t)}});})(); +//# sourceMappingURL=sha256-purejs.mjs.map diff --git a/backend/openflare/plugins/agent/nginx/pow_static/js/worker/sha256-webcrypto.mjs b/backend/openflare/plugins/agent/nginx/pow_static/js/worker/sha256-webcrypto.mjs new file mode 100644 index 00000000..7d32193b --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/js/worker/sha256-webcrypto.mjs @@ -0,0 +1,32 @@ +/* +@licstart The following is the entire license notice for the +JavaScript code in this page. + +Copyright (c) 2025 Xe Iaso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is +used under the terms of the Apache 2 license. + +@licend The above is the entire license notice +for the JavaScript code in this page. +*/ +(()=>{var h=new TextEncoder,y=async e=>{let s=h.encode(e);return await crypto.subtle.digest("SHA-256",s)},g=e=>e.reduce((s,a)=>s+a.toString(16).padStart(2,"0"),"");addEventListener("message",async({data:e})=>{let{data:s,difficulty:a,threads:d}=e,t=e.nonce,f=t===0,o=0,c=Math.floor(a/2),l=a%2!==0;for(;;){let u=await y(s+t),i=new Uint8Array(u),r=!0;for(let n=0;n>4!==0&&(r=!1),r){let n=g(i);postMessage({hash:n,data:s,difficulty:a,nonce:t});return}t+=d,o++,t%1!==0&&(t=Math.trunc(t)),f&&(o&1023)===0&&postMessage(t)}});})(); +//# sourceMappingURL=sha256-webcrypto.mjs.map diff --git a/backend/openflare/plugins/agent/nginx/pow_static/locales/en.json b/backend/openflare/plugins/agent/nginx/pow_static/locales/en.json new file mode 100644 index 00000000..4dffb969 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/locales/en.json @@ -0,0 +1,66 @@ +{ + "loading": "Loading...", + "why_am_i_seeing": "Why am I seeing this?", + "protected_by": "Protected by", + "protected_from": "From", + "made_with": "Made with ❤️ in 🇨🇦", + "mascot_design": "Mascot design by", + "ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.", + "anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.", + "hack_purpose": "Ultimately, this is a placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.", + "simplified_explanation": "This is a measure against bots and malicious requests similar to a CAPTCHA. However, instead of having to do work yourself, your browser is given a calculation task that it has to solve to ensure that it is a valid client. This concept is called
Proof of Work. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.", + "jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.", + "version_info": "This website is running Anubis version", + "try_again": "Try again", + "go_home": "Go home", + "contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at", + "connection_security": "Please wait a moment while we ensure the security of your connection.", + "javascript_required": "Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works. A no-JS solution is a work-in-progress.", + "benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.", + "difficulty": "Difficulty:", + "algorithm": "Algorithm:", + "compare": "Compare:", + "time": "Time", + "iters": "Iters", + "time_a": "Time A", + "iters_a": "Iters A", + "time_b": "Time B", + "iters_b": "Iters B", + "static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.", + "authorization_required": "Authorization required", + "cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain", + "access_denied": "Access Denied: error code", + "dronebl_entry": "DroneBL reported an entry", + "see_dronebl_lookup": "see", + "internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around", + "invalid_redirect": "Invalid redirect", + "redirect_not_parseable": "Redirect URL not parseable", + "redirect_domain_not_allowed": "Redirect domain not allowed", + "missing_required_forwarded_headers": "Missing required X-Forwarded-* headers", + "failed_to_sign_jwt": "failed to sign JWT", + "invalid_invocation": "Invalid invocation of MakeChallenge", + "client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.", + "oh_noes": "Oh noes!", + "benchmarking_anubis": "Benchmarking Anubis!", + "you_are_not_a_bot": "You are not a bot!", + "making_sure_not_bot": "Making sure you're not a bot!", + "celphase": "CELPHASE", + "js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?", + "js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?", + "js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.", + "js_context_not_secure": "Your context is not secure!", + "js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see MDN.", + "js_calculating": "Calculating...", + "js_missing_feature": "Missing feature", + "js_challenge_error": "Challenge error!", + "js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.", + "js_calculating_difficulty": "Calculating...
Difficulty:", + "js_speed": "Speed:", + "js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.", + "js_success": "Success!", + "js_done_took": "Done! Took", + "js_iterations": "iterations", + "js_finished_reading": "I've finished reading, continue →", + "js_calculation_error": "Calculation error!", + "js_calculation_error_msg": "Failed to calculate challenge:" +} diff --git a/backend/openflare/plugins/agent/nginx/pow_static/locales/zh-CN.json b/backend/openflare/plugins/agent/nginx/pow_static/locales/zh-CN.json new file mode 100644 index 00000000..19529607 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/pow_static/locales/zh-CN.json @@ -0,0 +1,66 @@ +{ + "loading": "加载中...", + "why_am_i_seeing": "为什么我会看到这个?", + "protected_by": "本网站由", + "protected_from": "保护,来自", + "made_with": "在 🇨🇦 用 ❤️ 制作", + "mascot_design": "吉祥物由", + "ai_companies_explanation": "您会看到这个画面,是因为网站管理员启用了 Anubis 来保护服务器,避免 AI 公司大量爬取网站内容。这类行为会导致网站崩溃,让所有用户都无法正常访问资源。", + "anubis_compromise": "Anubis 是一种折中做法。它采用了类似 Hashcash 的工作量证明机制(Proof-of-Work),该机制最初是为了减少垃圾邮件而提出。其核心概念是:对个别用户而言,额外的计算负担可以忽略,但对大规模爬虫来说,累积起来的成本将大幅增加,从而让爬取行为变得更困难。", + "hack_purpose": "最终,这是一个占位符解决方案,以便将更多时间用于指纹识别和识别无头浏览器(例如:通过它们如何进行字体渲染),从而无需向更可能是合法用户的用户呈现挑战工作量证明页面。", + "jshelter_note": "请注意,Anubis 需要使用现代 JavaScript 功能,而像 JShelter 这类插件可能会阻挡这些功能。请为此域名停用 JShelter 或类似的插件。", + "version_info": "这个网站正在运行的 Anubis 版本为", + "try_again": "再试一次", + "go_home": "返回首页", + "contact_webmaster": "或者您觉得您不应该被封锁,请联系网站管理员于", + "connection_security": "请稍等,我们需要在继续之前检查您的连接安全性。", + "javascript_required": "很遗憾,您必须启用 JavaScript 才能通过这项验证。这是因为 AI 公司已经改变了网站托管的社会契约,因此我们必须采取这样的保护机制。无需 JavaScript 的解决方案仍在开发中。", + "benchmark_requires_js": "运行基准测试工具需要启用 JavaScript。", + "difficulty": "难度:", + "algorithm": "算法:", + "compare": "比较:", + "time": "时间", + "iters": "迭代", + "time_a": "时间 A", + "iters_a": "迭代 A", + "time_b": "时间 B", + "iters_b": "迭代 B", + "static_check_endpoint": "这是提供给您的反向代理服务器使用的检查端点。", + "authorization_required": "需要认证", + "cookies_disabled": "您的浏览器目前已禁用 Cookie,为了确认您是合法用户,Anubis 需要启用 Cookie。 请您为此域名启用 Cookie", + "access_denied": "拒绝访问:错误代码", + "dronebl_entry": "DroneBL 报告了一条记录", + "see_dronebl_lookup": "见", + "internal_server_error": "内部服务器错误:管理员错误地配置了 Anubis。 请联系管理员要求他们检查日志", + "invalid_redirect": "无效的重定向", + "redirect_not_parseable": "重定向 URL 无法解析", + "redirect_domain_not_allowed": "重定向的域名并不允许", + "failed_to_sign_jwt": "签署 JWT 失败", + "invalid_invocation": "无效的 MakeChallenge 调用", + "client_error_browser": "客户端错误:请确保您的浏览器是最新版本并稍候再试。", + "oh_noes": "哎呀糟糕了!", + "benchmarking_anubis": "正在进行 Anubis 性能测试!", + "you_are_not_a_bot": "你不是机器人!", + "making_sure_not_bot": "正在确认你是不是机器人!", + "celphase": "CELPHASE 设计", + "js_web_crypto_error": "您的浏览器无法正常使用 web.crypto 组件。您是否通过安全连接(HTTPS)查看此网站?", + "js_web_workers_error": "您的浏览器并不支持 Web workers (Anubis 使用这个来避免冻结您的浏览器 )您有安装像是 JShelter 之类的插件吗?", + "js_cookies_error": "您的浏览器无法存储 Cookie。 Anubis 会使用 Cookie 存储签署的凭证,以判断用户是否已通过验证。请为此域名启用 Cookie 存储功能。 请注意,Anubis 存储的 Cookie 名称可能会变动,且其名称与内容不属于公开 API 的一部分。", + "js_context_not_secure": "您的内容并不安全", + "js_context_not_secure_msg": "请尝试使用 HTTPS 连接,或联系网站管理员设置 HTTPS。更多信息请参见 MDN。", + "js_calculating": "计算中...", + "js_missing_feature": "缺少功能", + "js_challenge_error": "挑战错误!", + "js_challenge_error_msg": "解决检查算法失败。 您可能会想要刷新页面。", + "js_calculating_difficulty": "计算中...
难度:", + "js_speed": "速度:", + "js_verification_longer": "验证所花的时间高于预期。 请不要刷新页面。", + "js_success": "成功!", + "js_done_took": "完成! 花费", + "js_iterations": "迭代", + "js_finished_reading": "我读完了,继续 →", + "js_calculation_error": "计算错误!", + "js_calculation_error_msg": "计算挑战失败:", + "missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 头", + "simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为工作量证明。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。" +} diff --git a/backend/openflare/plugins/agent/nginx/resty/ipmatcher.LICENSE b/backend/openflare/plugins/agent/nginx/resty/ipmatcher.LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/resty/ipmatcher.LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/backend/openflare/plugins/agent/nginx/resty/ipmatcher.lua b/backend/openflare/plugins/agent/nginx/resty/ipmatcher.lua new file mode 100644 index 00000000..36de6b95 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/resty/ipmatcher.lua @@ -0,0 +1,387 @@ +local base = require("resty.core.base") +local bit = require("bit") +local clear_tab = require("table.clear") +local new_tab = base.new_tab +local find_str = string.find +local tonumber = tonumber +local ipairs = ipairs +local pairs = pairs +local ffi = require "ffi" +local ffi_cdef = ffi.cdef +local ffi_copy = ffi.copy +local ffi_new = ffi.new +local C = ffi.C +local insert_tab = table.insert +local string = string +local setmetatable=setmetatable +local type = type +local error = error +local str_sub = string.sub +local str_byte = string.byte +local cur_level = ngx.config.subsystem == "http" and + require "ngx.errlog" .get_sys_filter_level() + +local AF_INET = 2 +local AF_INET6 = 10 +if ffi.os == "OSX" then + AF_INET6 = 30 +end + + +local _M = {_VERSION = 0.3} + + +ffi_cdef[[ + int inet_pton(int af, const char * restrict src, void * restrict dst); + uint32_t ntohl(uint32_t netlong); +]] + + +local parse_ipv4 +do + local inet = ffi_new("unsigned int [1]") + + function parse_ipv4(ip) + if not ip then + return false + end + + if C.inet_pton(AF_INET, ip, inet) ~= 1 then + return false + end + + return C.ntohl(inet[0]) + end +end +_M.parse_ipv4 = parse_ipv4 + +local parse_bin_ipv4 +do + local inet = ffi_new("unsigned int [1]") + + function parse_bin_ipv4(ip) + if not ip or #ip ~= 4 then + return false + end + + ffi_copy(inet, ip, 4) + return C.ntohl(inet[0]) + end +end + +local parse_ipv6 +do + local inets = ffi_new("unsigned int [4]") + + function parse_ipv6(ip) + if not ip then + return false + end + + if str_byte(ip, 1, 1) == str_byte('[') + and str_byte(ip, #ip) == str_byte(']') then + + -- strip square brackets around IPv6 literal if present + ip = str_sub(ip, 2, #ip - 1) + end + + if C.inet_pton(AF_INET6, ip, inets) ~= 1 then + return false + end + + local inets_arr = new_tab(4, 0) + for i = 0, 3 do + insert_tab(inets_arr, C.ntohl(inets[i])) + end + return inets_arr + end +end +_M.parse_ipv6 = parse_ipv6 + +local parse_bin_ipv6 +do + local inets = ffi_new("unsigned int [4]") + + function parse_bin_ipv6(ip) + if not ip or #ip ~= 16 then + return false + end + + ffi_copy(inets, ip, 16) + local inets_arr = new_tab(4, 0) + for i = 0, 3 do + insert_tab(inets_arr, C.ntohl(inets[i])) + end + return inets_arr + end +end + + +local mt = {__index = _M} + + + local ngx_log = ngx.log + local ngx_INFO = ngx.INFO +local function log_info(...) + if cur_level and ngx_INFO > cur_level then + return + end + + return ngx_log(ngx_INFO, ...) +end + + +local function split_ip(ip_addr_org) + local idx = find_str(ip_addr_org, "/", 1, true) + if not idx then + return ip_addr_org + end + + local ip_addr = str_sub(ip_addr_org, 1, idx - 1) + local ip_addr_mask = str_sub(ip_addr_org, idx + 1) + return ip_addr, tonumber(ip_addr_mask) +end +_M.split_ip = split_ip + + + local idxs = {} +local function gen_ipv6_idxs(inets_ipv6, mask) + clear_tab(idxs) + + for _, inet in ipairs(inets_ipv6) do + local valid_mask = mask + if valid_mask > 32 then + valid_mask = 32 + end + + if valid_mask == 32 then + insert_tab(idxs, inet) + else + insert_tab(idxs, bit.rshift(inet, 32 - valid_mask)) + end + + mask = mask - 32 + if mask <= 0 then + break + end + end + + return idxs +end + + +local function new(ips, with_value) + if not ips or type(ips) ~= "table" then + error("missing valid ip argument", 2) + end + + local parsed_ipv4s = {} + local parsed_ipv4s_mask = {} + local ipv4_match_all_value + + local parsed_ipv6s = {} + local parsed_ipv6s_mask = {} + local ipv6_values = {} + local ipv6s_values_idx = 1 + local ipv6_match_all_value + + local iter = with_value and pairs or ipairs + for a, b in iter(ips) do + local ip_addr_org, value + if with_value then + ip_addr_org = a + value = b + + else + ip_addr_org = b + value = true + end + + local ip_addr, ip_addr_mask = split_ip(ip_addr_org) + + local inet_ipv4 = parse_ipv4(ip_addr) + if inet_ipv4 then + ip_addr_mask = ip_addr_mask or 32 + if ip_addr_mask == 32 then + parsed_ipv4s[inet_ipv4] = value + + elseif ip_addr_mask == 0 then + ipv4_match_all_value = value + + else + local valid_inet_addr = bit.rshift(inet_ipv4, 32 - ip_addr_mask) + + parsed_ipv4s_mask[ip_addr_mask] = parsed_ipv4s_mask[ip_addr_mask] or {} + parsed_ipv4s_mask[ip_addr_mask][valid_inet_addr] = value + log_info("ipv4 mask: ", ip_addr_mask, + " valid inet: ", valid_inet_addr) + end + + goto continue + end + + local inets_ipv6 = parse_ipv6(ip_addr) + if inets_ipv6 then + ip_addr_mask = ip_addr_mask or 128 + if ip_addr_mask == 128 then + parsed_ipv6s[ip_addr] = value + + elseif ip_addr_mask == 0 then + ipv6_match_all_value = value + end + + parsed_ipv6s[ip_addr_mask] = parsed_ipv6s[ip_addr_mask] or {} + + local inets_idxs = gen_ipv6_idxs(inets_ipv6, ip_addr_mask) + local node = parsed_ipv6s[ip_addr_mask] + for i, inet in ipairs(inets_idxs) do + if i == #inets_idxs then + if with_value then + ipv6_values[ipv6s_values_idx] = value + node[inet] = ipv6s_values_idx + ipv6s_values_idx = ipv6s_values_idx + 1 + else + node[inet] = true + end + end + node[inet] = node[inet] or {} + node = node[inet] + end + + parsed_ipv6s_mask[ip_addr_mask] = true + + goto continue + end + + if not inet_ipv4 and not inets_ipv6 then + return nil, "invalid ip address: " .. ip_addr + end + + ::continue:: + end + + local ipv4_mask_arr = {} + for k, _ in pairs(parsed_ipv4s_mask) do + insert_tab(ipv4_mask_arr, k) + end + + local ipv6_mask_arr = {} + for k, _ in pairs(parsed_ipv6s_mask) do + insert_tab(ipv6_mask_arr, k) + end + + return setmetatable({ + ipv4 = parsed_ipv4s, + ipv4_mask = parsed_ipv4s_mask, + ipv4_mask_arr = ipv4_mask_arr, + ipv4_match_all_value = ipv4_match_all_value, + + ipv6 = parsed_ipv6s, + ipv6_mask = parsed_ipv6s_mask, + ipv6_mask_arr = ipv6_mask_arr, + ipv6_values = ipv6_values, + ipv6_match_all_value = ipv6_match_all_value, + }, mt) +end + +function _M.new(ips) + return new(ips, false) +end + +function _M.new_with_value(ips) + return new(ips, true) +end + + +local function match_ipv4(self, ip) + local ipv4s = self.ipv4 + local value = ipv4s[ip] + if value ~= nil then + return value + end + + local ipv4_mask = self.ipv4_mask + if self.ipv4_match_all_value ~= nil then + return self.ipv4_match_all_value -- match any ip + end + + for _, mask in ipairs(self.ipv4_mask_arr) do + local valid_inet_addr = bit.rshift(ip, 32 - mask) + + log_info("ipv4 mask: ", mask, + " valid inet: ", valid_inet_addr) + + value = ipv4_mask[mask][valid_inet_addr] + if value ~= nil then + return value + end + end + + return false +end + +local function match_ipv6(self, ip) + local ipv6s = self.ipv6 + if self.ipv6_match_all_value ~= nil then + return self.ipv6_match_all_value -- match any ip + end + + for _, mask in ipairs(self.ipv6_mask_arr) do + local node = ipv6s[mask] + local inet_idxs = gen_ipv6_idxs(ip, mask) + for _, inet in ipairs(inet_idxs) do + if not node[inet] then + break + else + node = node[inet] + if node == true then + return true + end + if type(node) == "number" then + -- fetch with the ipv6s_values_idx + return self.ipv6_values[node] + end + end + end + end + + return false +end + +function _M.match(self, ip) + local inet_ipv4 = parse_ipv4(ip) + if inet_ipv4 then + return match_ipv4(self, inet_ipv4) + end + + local inets_ipv6 = parse_ipv6(ip) + if not inets_ipv6 then + return false, "invalid ip address, not ipv4 and ipv6" + end + + local ipv6s = self.ipv6 + local value = ipv6s[ip] + if value ~= nil then + return value + end + + return match_ipv6(self, inets_ipv6) +end + + +function _M.match_bin(self, bin_ip) + local inet_ipv4 = parse_bin_ipv4(bin_ip) + if inet_ipv4 then + return match_ipv4(self, inet_ipv4) + end + + local inets_ipv6 = parse_bin_ipv6(bin_ip) + if not inets_ipv6 then + return false, "invalid ip address, not ipv4 and ipv6" + end + + return match_ipv6(self, inets_ipv6) +end + + +return _M diff --git a/backend/openflare/plugins/agent/nginx/runtime_access.go b/backend/openflare/plugins/agent/nginx/runtime_access.go new file mode 100644 index 00000000..d2dbe649 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/runtime_access.go @@ -0,0 +1,32 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "Wavelet/openflare/plugins/agent/runtimeuser" +) + +// OpenFlareRuntimeUser is the shared OS account for the agent process and +// OpenResty worker processes. +const OpenFlareRuntimeUser = runtimeuser.Name + +// OpenRestyWorkerUser is an alias kept for internal call sites. +const OpenRestyWorkerUser = runtimeuser.Name + +// EnsureWorldTraversablePath makes targetDir and its ancestors world-traversable. +func EnsureWorldTraversablePath(targetDir string) error { + return runtimeuser.EnsurePathOwnership(targetDir, nginxDirPerm, nginxConfigFilePerm) +} + +// EnsureWorkerReadableTree normalizes ownership and modes under root for the +// shared runtime user. +func EnsureWorkerReadableTree(rootDir string) error { + return runtimeuser.EnsurePathOwnership(rootDir, nginxDirPerm, nginxConfigFilePerm) +} + +// EnsureWorkerReadAccess makes agent-managed runtime paths accessible to the +// shared runtime user. +func (m *Manager) EnsureWorkerReadAccess() error { + return m.ensureOpenRestyWorkerReadAccess() +} diff --git a/backend/openflare/plugins/agent/nginx/runtime_access_test.go b/backend/openflare/plugins/agent/nginx/runtime_access_test.go new file mode 100644 index 00000000..7df4139c --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/runtime_access_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureWorkerReadableTreeFixesRestrictedPagesFiles(t *testing.T) { + tempDir := t.TempDir() + pagesDir := filepath.Join(tempDir, "data", "var", "lib", "openflare", "pages") + releaseDir := filepath.Join(pagesDir, "deployments", "1", "releases", "abc123") + if err := os.MkdirAll(releaseDir, 0o700); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + indexPath := filepath.Join(releaseDir, "index.html") + if err := os.WriteFile(indexPath, []byte(""), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + if err := EnsureWorldTraversablePath(pagesDir); err != nil { + t.Fatalf("EnsureWorldTraversablePath failed: %v", err) + } + if err := EnsureWorkerReadableTree(pagesDir); err != nil { + t.Fatalf("EnsureWorkerReadableTree failed: %v", err) + } + + info, err := os.Stat(indexPath) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + if info.Mode().Perm() != nginxConfigFilePerm { + t.Fatalf("expected index.html mode %o, got %o", nginxConfigFilePerm, info.Mode().Perm()) + } + etcInfo, err := os.Stat(filepath.Join(tempDir, "data", "var")) + if err != nil { + t.Fatalf("Stat var failed: %v", err) + } + if etcInfo.Mode().Perm()&0o005 == 0 { + t.Fatalf("expected var directory to be world-traversable, got %o", etcInfo.Mode().Perm()) + } +} + +func TestManagerEnsureWorkerReadAccessIncludesPagesDir(t *testing.T) { + tempDir := t.TempDir() + dataDir := filepath.Join(tempDir, "data") + pagesRoot := filepath.Join(dataDir, "var", "lib", "openflare", "pages") + releaseDir := filepath.Join(pagesRoot, "deployments", "1", "releases", "abc123") + if err := os.MkdirAll(releaseDir, 0o700); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("ok"), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + manager := &Manager{PagesDir: pagesRoot} + if err := manager.EnsureWorkerReadAccess(); err != nil { + t.Fatalf("EnsureWorkerReadAccess failed: %v", err) + } + + info, err := os.Stat(filepath.Join(tempDir, "data")) + if err != nil { + t.Fatalf("Stat data failed: %v", err) + } + if info.Mode().Perm()&0o005 == 0 { + t.Fatalf("expected data directory to be world-traversable, got %o", info.Mode().Perm()) + } + indexInfo, err := os.Stat(filepath.Join(releaseDir, "index.html")) + if err != nil { + t.Fatalf("Stat index failed: %v", err) + } + if indexInfo.Mode().Perm() != nginxConfigFilePerm { + t.Fatalf("expected index.html mode %o, got %o", nginxConfigFilePerm, indexInfo.Mode().Perm()) + } +} diff --git a/backend/openflare/plugins/agent/nginx/sw_assets.go b/backend/openflare/plugins/agent/nginx/sw_assets.go new file mode 100644 index 00000000..4d9d68aa --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/sw_assets.go @@ -0,0 +1,114 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "Wavelet/openflare/plugins/agent/protocol" +) + +const openRestySWRuntimeLua = `local _M = {} + +local source = debug.getinfo(1, "S").source or "" +if string.sub(source, 1, 1) == "@" then + local script_path = string.sub(source, 2) + local base_dir = string.match(script_path, "^(.*)/sw/[^/]+%.lua$") + if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then + package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path + end +end + +local function is_real_browser(ua) + if not ua or ua == "" then return false end + -- Chrome/Edge/CentOS-style: "Chrome/120" (pattern mode: %d = digit) + if string.find(ua, "Chrome/%d", 1) then return true end + -- Firefox: "Firefox/120" + if string.find(ua, "Firefox/%d", 1) then return true end + -- Safari (non-Chrome, e.g. "Version/17.0 Safari") + if not string.find(ua, "Chrome", 1, true) and string.find(ua, "Safari", 1, true) then return true end + return false +end + +local function pass_through() + return true +end + +function _M.check() + local ua = ngx.var.http_user_agent or "" + if not is_real_browser(ua) then return pass_through() end + + local uri = ngx.var.uri or "" + if uri ~= "/" then return pass_through() end + + if ngx.req.get_method and ngx.req.get_method() ~= "GET" then return pass_through() end + + local cookie = ngx.var["cookie___openflare_sw"] + if cookie and cookie ~= "" then return pass_through() end + + -- intercept: internal redirect to challenge page, which registers SW + sets cookie + local redir = ngx.var.scheme .. "://" .. ngx.var.host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or "") + ngx.req.set_uri_args({ redir = redir }) + return ngx.exec("/__openflare_sw_challenge") +end + +return _M +` + +const openRestySWChallengeLua = `local args = ngx.req.get_uri_args() +local redir = args["redir"] or "/" + +-- Escape redir for embedding inside a JS string literal within an HTML +-- + + +]]) +` + +// ManagedSWLuaFiles returns embedded Lua assets for the SW offline challenge. +func ManagedSWLuaFiles() []protocol.SupportFile { + return []protocol.SupportFile{ + {Path: "sw/runtime.lua", Content: openRestySWRuntimeLua}, + {Path: "sw/challenge.lua", Content: openRestySWChallengeLua}, + } +} diff --git a/backend/openflare/plugins/agent/nginx/sw_assets_test.go b/backend/openflare/plugins/agent/nginx/sw_assets_test.go new file mode 100644 index 00000000..cf3b4020 --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/sw_assets_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package nginx + +import ( + "os" + "path/filepath" + "testing" + + lua "github.com/yuin/gopher-lua" +) + +func TestSWRuntimeAndChallenge(t *testing.T) { + state := lua.NewState() + defer state.Close() + + runtimePath := filepath.Join(t.TempDir(), "runtime.lua") + if err := os.WriteFile(runtimePath, []byte(openRestySWRuntimeLua), 0o644); err != nil { + t.Fatal(err) + } + challengePath := filepath.Join(t.TempDir(), "challenge.lua") + if err := os.WriteFile(challengePath, []byte(openRestySWChallengeLua), 0o644); err != nil { + t.Fatal(err) + } + specPath, err := filepath.Abs("sw_runtime_spec.lua") + if err != nil { + t.Fatal(err) + } + state.SetGlobal("SW_RUNTIME_PATH", lua.LString(runtimePath)) + state.SetGlobal("SW_CHALLENGE_PATH", lua.LString(challengePath)) + if err := state.DoFile(specPath); err != nil { + t.Fatalf("SW runtime/challenge specification failed: %v", err) + } +} diff --git a/backend/openflare/plugins/agent/nginx/sw_runtime_spec.lua b/backend/openflare/plugins/agent/nginx/sw_runtime_spec.lua new file mode 100644 index 00000000..f027e3ab --- /dev/null +++ b/backend/openflare/plugins/agent/nginx/sw_runtime_spec.lua @@ -0,0 +1,175 @@ +local runtime_path = assert(SW_RUNTIME_PATH, "SW_RUNTIME_PATH is required") +local challenge_path = assert(SW_CHALLENGE_PATH, "SW_CHALLENGE_PATH is required") + +local function assert_equal(actual, expected, message) + if actual ~= expected then + error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2) + end +end + +-- Stable tables: never rebind `exec_calls` / `redir_args` (closures capture +-- the upvalue slot; rebinding can leave stale values visible under +-- gopher-lua across long test sequences). Clear them in place instead. +local output = {} +local exec_calls = {} +local redir_args = {} + +local function clear_state() + for i = 1, #exec_calls do exec_calls[i] = nil end + redir_args.redir = nil +end + +ngx = { + var = {}, + header = {}, + exec = function(uri) + exec_calls[#exec_calls + 1] = uri + return true + end, + say = function(body) output.body = body end, + req = { + get_uri_args = function() return redir_args end, + set_uri_args = function(args) redir_args.redir = args.redir end, + }, +} + +local function load_runtime() + local chunk = assert(loadfile(runtime_path)) + return chunk() +end + +local function reset_request(user_agent, uri, cookie, args, method) + clear_state() + ngx.var = { + http_user_agent = user_agent, + uri = uri or "/", + scheme = "https", + host = "example.com", + args = args, + ["cookie___openflare_sw"] = cookie, + } + ngx.req.get_method = function() return method or "GET" end +end + +local function test_module_contract() + local runtime = load_runtime() + assert_equal(type(runtime), "table", "sw.runtime must return a module table, not true/nil") + assert_equal(type(runtime.check), "function", "sw.runtime must export check()") +end + +local function test_non_browser_ua_passes_through() + local runtime = load_runtime() + reset_request("curl/8.0.1") + assert_equal(runtime.check(), true, "non-browser UA passes through") + assert_equal(#exec_calls, 0, "non-browser UA must not intercept") + + reset_request("") + assert_equal(runtime.check(), true, "empty UA passes through") + + reset_request(nil) + assert_equal(runtime.check(), true, "missing UA passes through") +end + +local function test_browser_ua_non_get_passes_through() + local runtime = load_runtime() + reset_request( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "/", + nil, + nil, + "POST" + ) + assert_equal(runtime.check(), true, "non-GET request passes through") + assert_equal(#exec_calls, 0, "non-GET request must not be intercepted") +end + +local function test_browser_ua_with_cookie_passes_through() + local runtime = load_runtime() + reset_request( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "/", + "1" + ) + assert_equal(runtime.check(), true, "browser UA with cookie passes through") + assert_equal(#exec_calls, 0, "cookie holder must not be intercepted") +end + +local function test_browser_ua_root_without_cookie_intercepts() + local runtime = load_runtime() + local chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + reset_request(chrome, "/") + runtime.check() + assert_equal(#exec_calls, 1, "browser without cookie on / must be intercepted once") + assert_equal(exec_calls[1], "/__openflare_sw_challenge", "intercept targets the challenge page") + assert_equal(redir_args.redir, "https://example.com/", "redir arg preserves scheme+host+uri") + + reset_request(chrome, "/", nil, "a=1&b=2") + runtime.check() + assert_equal(#exec_calls, 1, "second request also intercepted") + assert_equal(redir_args.redir, "https://example.com/?a=1&b=2", "redir arg keeps the query string") + + reset_request("Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", "/") + runtime.check() + assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Firefox intercepted") + + reset_request( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "/" + ) + runtime.check() + assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Safari intercepted") +end + +local function test_browser_ua_non_root_passes_through() + local runtime = load_runtime() + reset_request( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "/about" + ) + assert_equal(runtime.check(), true, "non-root uri passes through") + assert_equal(#exec_calls, 0, "non-root uri must not be intercepted") +end + +local function run_challenge(redir_value) + output.body = nil + ngx.header = {} + redir_args.redir = redir_value + local chunk = assert(loadfile(challenge_path)) + chunk() + return output.body +end + +local function test_challenge_embeds_plain_redir() + local body = run_challenge("https://example.com/page?a=1&b=2") + assert_equal( + string.find(body, 'location.replace("https://example.com/page?a=1&b=2")', 1, true) ~= nil, + true, + "plain redir embedded verbatim" + ) +end + +local function test_challenge_escapes_script_breakout() + local payload = '"/>' + local body = run_challenge(payload) + assert_equal(string.find(body, '"> + + +`; + +export default html; diff --git a/frontend/lib/openflare/origin-error-page-templates/index.ts b/frontend/lib/openflare/origin-error-page-templates/index.ts new file mode 100644 index 00000000..d82286f3 --- /dev/null +++ b/frontend/lib/openflare/origin-error-page-templates/index.ts @@ -0,0 +1,46 @@ +/** + * 内置源站错误页 HTML 模板目录。 + * 每个模板对应本目录下一个独立 `.ts` 模块(export default 完整 HTML 字符串)。 + * 新增模板:新增 `*.ts` 并在下方数组注册。 + * + * 模板须使用占位符 {{status}} / {{host}},禁止写死状态码。 + */ +import baohausHtml from './baohaus'; +import lineStyleHtml from './line-style'; +import minimalistHtml from './minimalist'; + +export type OriginErrorPageTemplate = { + /** 稳定 ID,与文件名一致 */ + id: string; + /** 下拉展示名称 */ + name: string; + /** 完整 HTML,含 {{status}} / {{host}} */ + html: string; +}; + +export const ORIGIN_ERROR_PAGE_TEMPLATES: OriginErrorPageTemplate[] = [ + { + id: 'minimalist', + name: '极简白底', + html: minimalistHtml, + }, + { + id: 'line-style', + name: '线框拓扑', + html: lineStyleHtml, + }, + { + id: 'baohaus', + name: '包豪斯', + html: baohausHtml, + }, +]; + +/** 默认预制模板(前端预览空 HTML 回退 & 加载模板默认选中) */ +export const DEFAULT_ORIGIN_ERROR_PAGE_TEMPLATE_ID = 'minimalist'; + +export function getOriginErrorPageTemplate( + id: string, +): OriginErrorPageTemplate | undefined { + return ORIGIN_ERROR_PAGE_TEMPLATES.find((item) => item.id === id); +} diff --git a/frontend/lib/openflare/origin-error-page-templates/line-style.ts b/frontend/lib/openflare/origin-error-page-templates/line-style.ts new file mode 100644 index 00000000..4cc44018 --- /dev/null +++ b/frontend/lib/openflare/origin-error-page-templates/line-style.ts @@ -0,0 +1,309 @@ +/** 内置错误页模板:line-style */ +const html = ` + + + + + + {{status}} | OpenFlare + + + +
+
+
{{status}}
+
Origin Unavailable
+
+ +
+ +
+ + +
+ + + + +`; + +export default html; diff --git a/frontend/lib/openflare/origin-error-page-templates/minimalist.ts b/frontend/lib/openflare/origin-error-page-templates/minimalist.ts new file mode 100644 index 00000000..326bf3b7 --- /dev/null +++ b/frontend/lib/openflare/origin-error-page-templates/minimalist.ts @@ -0,0 +1,109 @@ +/** 内置错误页模板:minimalist(默认) */ +const html = ` + + + + + + {{status}} | OpenFlare + + + +
+

{{status}}

+

+ The upstream server is unreachable. Please try again later or contact the site administrator if the problem persists. +

+

{{host}}

+ +
+ + +`; + +export default html; diff --git a/frontend/lib/openflare/status-code-tags.ts b/frontend/lib/openflare/status-code-tags.ts new file mode 100644 index 00000000..592614e8 --- /dev/null +++ b/frontend/lib/openflare/status-code-tags.ts @@ -0,0 +1,100 @@ +/** Matches pkg/render/openresty StatusCodeMin / StatusCodeMax. */ +export const STATUS_CODE_MIN = 400; +export const STATUS_CODE_MAX = 599; + +export const DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS = ['500-599'] as const; + +/** + * Parse a single tag such as "502" or "500-599". + * Bounds must fall within 400–599 inclusive (aligned with Go ParseStatusCodeTag). + */ +export function parseStatusCodeTag(tag: string): { + lo: number; + hi: number; +} { + const trimmed = tag.trim(); + if (!trimmed) { + throw new Error('状态码标签不能为空'); + } + + let lo: number; + let hi: number; + + const dash = trimmed.indexOf('-'); + if (dash >= 0) { + lo = Number.parseInt(trimmed.slice(0, dash), 10); + hi = Number.parseInt(trimmed.slice(dash + 1), 10); + if (Number.isNaN(lo) || Number.isNaN(hi)) { + throw new Error(`无效状态码区间: ${trimmed}`); + } + } else { + lo = Number.parseInt(trimmed, 10); + if (Number.isNaN(lo)) { + throw new Error(`无效状态码: ${trimmed}`); + } + hi = lo; + } + + if (lo > hi) { + throw new Error(`状态码区间左右端点反序: ${trimmed}`); + } + if (lo < STATUS_CODE_MIN || hi > STATUS_CODE_MAX) { + throw new Error( + `状态码须在 ${STATUS_CODE_MIN}–${STATUS_CODE_MAX}: ${trimmed}`, + ); + } + + return { lo, hi }; +} + +/** Expand tags into a sorted unique list of integers. */ +export function expandStatusCodeTags(tags: string[]): number[] { + const set = new Set(); + for (const tag of tags) { + const { lo, hi } = parseStatusCodeTag(tag); + for (let code = lo; code <= hi; code += 1) { + set.add(code); + } + } + return Array.from(set).sort((a, b) => a - b); +} + +/** Soft validator for TagsInput: returns error message or null if ok. */ +export function validateStatusCodeTagMessage(tag: string): string | null { + try { + parseStatusCodeTag(tag); + return null; + } catch (error) { + return error instanceof Error ? error.message : '无效状态码标签'; + } +} + +/** Validate a full tag list before save (must be non-empty and expand cleanly). */ +export function validateStatusCodeTags(tags: string[]): void { + if (tags.length === 0) { + throw new Error('请至少添加一个状态码标签'); + } + const codes = expandStatusCodeTags(tags); + if (codes.length === 0) { + throw new Error('状态码展开结果为空'); + } +} + +export function parseStatusCodeTagsJSON(raw: string | undefined): string[] { + if (!raw || !raw.trim()) { + return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS]; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS]; + } + const tags = parsed + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); + return tags.length > 0 ? tags : [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS]; + } catch { + return [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS]; + } +} diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 51e52f7c..b370c0e0 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -212,6 +212,10 @@ export interface TaskExecution { export interface ListTaskExecutionsRequest { status?: TaskExecutionStatus; task_type?: string; + /** Prefix match on stored asynq task type (ignored when task_type / task_types is set). */ + task_type_prefix?: string; + /** Comma-separated exact asynq task types for IN filter (ignored when task_type is set). */ + task_types?: string; page?: number; page_size?: number; } @@ -413,6 +417,8 @@ export interface LogDatabaseStatus { retention_days: Record; /** 当前主库的合法迁移目标 */ available_targets: string[]; + /** ClickHouse 运行指标(仅主库为 ClickHouse 时) */ + clickhouse?: unknown; } /** diff --git a/frontend/lib/services/admin/user.service.ts b/frontend/lib/services/admin/user.service.ts index 97243211..50c46afe 100644 --- a/frontend/lib/services/admin/user.service.ts +++ b/frontend/lib/services/admin/user.service.ts @@ -4,8 +4,8 @@ import type { CreateUserRequest, ListUsersRequest, ListUsersResponse, - UpdateUserStatusRequest, UpdateUserRequest, + UpdateUserStatusRequest, } from './types'; export class AdminUserService extends BaseService { diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index c859a960..4ec11f84 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -90,7 +90,7 @@ function getRequestKey(config: { */ apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - // Browser must use same-origin relative URLs so Session Cookie hits Next rewrite. + // Browser must use same-origin relative URLs so Session Cookie hits Next rewrite (3010→3000). if (typeof window !== 'undefined') { config.baseURL = getApiBaseUrl(); } diff --git a/frontend/lib/services/core/config.ts b/frontend/lib/services/core/config.ts index f0d518e5..e99770c5 100644 --- a/frontend/lib/services/core/config.ts +++ b/frontend/lib/services/core/config.ts @@ -1,24 +1,19 @@ /** * API 配置 - * - * 浏览器默认走同源相对路径(`/api/...`),由 `next.config` rewrites 反代到后端, - * 避免 dev 下跨域。仅当设置了 `NEXT_PUBLIC_WAVELET_BACKEND_URL` 时浏览器才直连后端 - *(静态导出 embed / WebSocket 等场景)。 */ /** * 获取 API 基础 URL - * @returns API 基础 URL;浏览器侧空字符串表示同源相对路径 + * @returns API 基础 URL */ export function getApiBaseUrl(): string { if (typeof window === 'undefined') { return ( process.env.WAVELET_BACKEND_URL || process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || - 'http://localhost:8000' + 'http://localhost:3000' ); } - // 空字符串 → axios 请求 `/api/...`,经 Next rewrites 到后端,无 CORS 问题 return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ''; } @@ -30,6 +25,11 @@ export const apiConfig = { baseURL: getApiBaseUrl(), /** 超时时间(毫秒) */ timeout: 15000, + /** + * 文件上传超时(毫秒)。 + * Pages 部署包上限 100 MiB,需覆盖上传 + 服务端解压校验与入库时间。 + */ + uploadTimeout: 10 * 60 * 1000, /** 携带凭证 */ withCredentials: true, } as const; diff --git a/frontend/lib/services/core/search-params.ts b/frontend/lib/services/core/search-params.ts new file mode 100644 index 00000000..2c2e2488 --- /dev/null +++ b/frontend/lib/services/core/search-params.ts @@ -0,0 +1,22 @@ +/** + * Serialize query params for Gin-compatible repeated array keys. + * Axios default uses hosts[]= which Gin QueryArray("hosts") ignores. + */ +export function serializeSearchParams( + params?: Record, +): string { + if (!params) return ''; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item === undefined || item === null || item === '') continue; + search.append(key, String(item)); + } + continue; + } + search.append(key, String(value)); + } + return search.toString(); +} diff --git a/frontend/lib/services/db-manage/db-manage.service.ts b/frontend/lib/services/db-manage/db-manage.service.ts index a4726d19..9bc3ced5 100644 --- a/frontend/lib/services/db-manage/db-manage.service.ts +++ b/frontend/lib/services/db-manage/db-manage.service.ts @@ -61,7 +61,7 @@ export class DbManageService extends BaseService { const disposition = response.headers['content-disposition'] as | string | undefined; - let filename = 'wavelet_export'; + let filename = 'openflare_export'; if (disposition) { const match = disposition.match(/filename="?([^";]+)"?/); if (match) filename = match[1]; diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 3720d1c1..ad984e54 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -23,13 +23,27 @@ import { import { AuthService } from './auth'; import { ConfigService } from './config'; import { DbManageService } from './db-manage'; -import { - AdminMessageGatewayService, - UserMessageGatewayService, -} from './message-gateway'; import { PushService } from './push'; import { AdminUploadService, UploadService } from './upload'; import { UserService } from './user'; +import { + AccessLogService, + ApplyLogService, + ConfigVersionService, + DashboardService, + DnsAccountService, + NodeService, + OptionService, + OriginService, + PagesService, + ProxyRouteService, + StatusService, + TlsCertificateService, + UptimeKumaService, + WafService, + ZoneDomainService, + ZoneService, +} from './openflare'; const services = { auth: AuthService, @@ -47,8 +61,22 @@ const services = { adminUpload: AdminUploadService, dbManage: DbManageService, push: PushService, - adminMessageGateway: AdminMessageGatewayService, - userMessageGateway: UserMessageGatewayService, + openflareNode: NodeService, + openflareProxyRoute: ProxyRouteService, + openflareConfigVersion: ConfigVersionService, + openflareApplyLog: ApplyLogService, + openflareDashboard: DashboardService, + openflareWaf: WafService, + openflareZone: ZoneService, + openflareZoneDomain: ZoneDomainService, + openflareTls: TlsCertificateService, + openflareDns: DnsAccountService, + openflarePages: PagesService, + openflareOrigin: OriginService, + openflareAccessLog: AccessLogService, + openflareOption: OptionService, + openflareUptimeKuma: UptimeKumaService, + openflareStatus: StatusService, } as const; export default services; @@ -173,19 +201,6 @@ export type { } from './db-manage'; export { PushService } from './push'; -export { - AdminMessageGatewayService, - UserMessageGatewayService, -} from './message-gateway'; -export type { - MessageChannel, - MessageChannelDefinition, - CreateMessageChannelRequest, - UpdateMessageChannelRequest, - PublicMessageChannel, - MessageBinding, - BindMessageChannelRequest, -} from './message-gateway'; export type { PushEvent, PushHistory, @@ -195,3 +210,56 @@ export type { UpdatePushEventRequest, TestPushRequest, } from './push'; + +// ==================== OpenFlare 业务服务导出 ==================== + +export { + OpenFlareBaseService, + NodeService, + ProxyRouteService, + ConfigVersionService, + ApplyLogService, + DashboardService, + WafService, + TlsCertificateService, + DnsAccountService, + PagesService, + OriginService, + AccessLogService, + OptionService, + StatusService, + UptimeKumaService, +} from './openflare'; + +export type { + NodeItem, + ProxyRouteItem, + ProxyRouteZoneDomain, + ProxyRouteConfigSection, + ConfigVersionSummary, + ConfigVersionDetail, + ConfigDiffResult, + ConfigPreviewResult, + ApplyLogList, + DashboardOverview, + WAFIPGroup, + WAFRule, + WAFRuleGraph, + WAFRuleNode, + WAFRuleGroup, + WAFSiteRuleGroups, + TlsCertificateItem, + DnsAccountItem, + PagesProject, + PagesDeployment, + PagesSource, + PagesSourceActionReceipt, + PagesSourceStatus, + OriginItem, + OriginDetail, + AccessLogList, + AccessLogOverview, + OptionItem, + GeoIPLookupResult, + OpenFlarePublicStatus, +} from './openflare'; diff --git a/frontend/lib/services/message-gateway/admin.service.ts b/frontend/lib/services/message-gateway/admin.service.ts deleted file mode 100644 index 9852dbf8..00000000 --- a/frontend/lib/services/message-gateway/admin.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -import { BaseService } from '@/lib/services/core'; -import type { - CreateMessageChannelRequest, - MessageChannel, - MessageChannelDefinition, - UpdateMessageChannelRequest, -} from './types'; - -export class AdminMessageGatewayService extends BaseService { - protected static readonly basePath = '/api/v1/admin/message-gateway'; - - static async list(): Promise { - return this.get('/channels'); - } - - static async definitions(): Promise { - return this.get('/channels/definitions'); - } - - static async create( - data: CreateMessageChannelRequest, - ): Promise { - return this.post('/channels', data); - } - - static async update( - id: string, - data: UpdateMessageChannelRequest, - ): Promise { - return this.patch(`/channels/${id}`, data); - } - - static async remove(id: string): Promise { - return this.delete(`/channels/${id}`); - } - - static async test(id: string): Promise { - return this.post(`/channels/${id}/test`); - } -} diff --git a/frontend/lib/services/message-gateway/index.ts b/frontend/lib/services/message-gateway/index.ts deleted file mode 100644 index 871e2d3e..00000000 --- a/frontend/lib/services/message-gateway/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -export * from './types'; -export * from './admin.service'; -export * from './user.service'; diff --git a/frontend/lib/services/message-gateway/types.ts b/frontend/lib/services/message-gateway/types.ts deleted file mode 100644 index 02a9cd9c..00000000 --- a/frontend/lib/services/message-gateway/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -export type MessageChannelType = 'telegram' | 'qq'; - -export interface MessageChannelField { - key: string; - type?: string; - required?: boolean; -} - -export interface MessageChannelDefinition { - type: MessageChannelType | string; - name: string; - fields: MessageChannelField[]; -} - -export interface MessageChannel { - id: string; - name: string; - type: MessageChannelType | string; - owner_scope: string; - enabled: boolean; - bot_token?: string; - app_id?: string; - app_secret?: string; - base_url?: string; - portal_host?: string; - created_at: string; - updated_at: string; -} - -export interface CreateMessageChannelRequest { - name: string; - type: string; - enabled?: boolean; - bot_token?: string; - app_id?: string; - app_secret?: string; - base_url?: string; - portal_host?: string; -} - -export interface UpdateMessageChannelRequest { - name?: string; - enabled?: boolean; - bot_token?: string; - app_id?: string; - app_secret?: string; - base_url?: string; - portal_host?: string; -} - -export interface PublicMessageChannel { - id: string; - name: string; - type: MessageChannelType | string; -} - -export interface MessageBinding { - id: string; - user_id: string; - channel_id: string; - channel_name: string; - channel_type: string; - platform_user_id: string; - created_at: string; -} - -export interface BindMessageChannelRequest { - channel_id: string; - code: string; -} diff --git a/frontend/lib/services/message-gateway/user.service.ts b/frontend/lib/services/message-gateway/user.service.ts deleted file mode 100644 index bedfbe82..00000000 --- a/frontend/lib/services/message-gateway/user.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -import { BaseService } from '@/lib/services/core'; -import type { - BindMessageChannelRequest, - MessageBinding, - PublicMessageChannel, -} from './types'; - -export class UserMessageGatewayService extends BaseService { - protected static readonly basePath = '/api/v1/message-gateway'; - - static async listBindings(): Promise { - return this.get('/bindings'); - } - - static async listChannels(): Promise { - return this.get('/channels'); - } - - static async bind(data: BindMessageChannelRequest): Promise { - return this.post('/bindings', data); - } - - static async unbind(id: string): Promise { - return this.delete(`/bindings/${id}`); - } -} diff --git a/frontend/lib/services/openflare/access-log.service.ts b/frontend/lib/services/openflare/access-log.service.ts new file mode 100644 index 00000000..236ecfdb --- /dev/null +++ b/frontend/lib/services/openflare/access-log.service.ts @@ -0,0 +1,98 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + AccessLogCleanupPayload, + AccessLogCleanupResult, + AccessLogFilters, + AccessLogIPAnalysis, + AccessLogIPAnalysisFilters, + AccessLogIPSummaryFilters, + AccessLogIPSummaryList, + AccessLogIPTrend, + AccessLogIPTrendFilters, + AccessLogList, + AccessLogOverview, + AccessLogOverviewFilters, + FoldedAccessLogFilters, + FoldedAccessLogIPFilters, + FoldedAccessLogIPList, + FoldedAccessLogList, +} from './types'; + +function buildSearchParams(filters: object): Record { + const params: Record = {}; + Object.entries(filters as Record).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return; + if (Array.isArray(value)) { + const items = value + .map((item) => String(item).trim()) + .filter((item) => item !== ''); + if (items.length === 0) return; + params[key] = items; + return; + } + params[key] = value; + }); + return params; +} + +export class AccessLogService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/access-logs'; + + static list(filters: AccessLogFilters = {}): Promise { + return this.get('/', buildSearchParams(filters)); + } + + static getOverview( + filters: AccessLogOverviewFilters = {}, + ): Promise { + return this.get('/overview', buildSearchParams(filters)); + } + + static listFolds( + filters: FoldedAccessLogFilters, + ): Promise { + return this.get('/folds', buildSearchParams(filters)); + } + + static listFoldIPs( + filters: FoldedAccessLogIPFilters, + ): Promise { + return this.get( + '/folds/ip-summary', + buildSearchParams(filters), + ); + } + + static listIPSummaries( + filters: AccessLogIPSummaryFilters = {}, + ): Promise { + return this.get( + '/ip-summary', + buildSearchParams(filters), + ); + } + + static getIPTrend( + filters: AccessLogIPTrendFilters, + ): Promise { + return this.get( + '/ip-summary/trend', + buildSearchParams(filters), + ); + } + + static getIPAnalysis( + filters: AccessLogIPAnalysisFilters, + ): Promise { + return this.get( + '/ip-summary/analysis', + buildSearchParams(filters), + ); + } + + static cleanup( + payload: AccessLogCleanupPayload, + ): Promise { + return this.post('/cleanup', payload); + } +} diff --git a/frontend/lib/services/openflare/apply-log.service.ts b/frontend/lib/services/openflare/apply-log.service.ts new file mode 100644 index 00000000..64f84127 --- /dev/null +++ b/frontend/lib/services/openflare/apply-log.service.ts @@ -0,0 +1,34 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + ApplyLogCleanupPayload, + ApplyLogCleanupResult, + ApplyLogList, + ApplyLogListQuery, +} from './types'; + +export class ApplyLogService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/apply-logs'; + + static list(query: ApplyLogListQuery = {}): Promise { + const params: Record = {}; + + const normalizedNodeId = query.node_id?.trim(); + if (normalizedNodeId) { + params.node_id = normalizedNodeId; + } + if (query.pageNo) { + params.pageNo = query.pageNo; + } + if (query.pageSize) { + params.pageSize = query.pageSize; + } + + return this.get('/', params); + } + + static cleanup( + payload: ApplyLogCleanupPayload, + ): Promise { + return this.post('/cleanup', payload); + } +} diff --git a/frontend/lib/services/openflare/base.service.ts b/frontend/lib/services/openflare/base.service.ts new file mode 100644 index 00000000..599bd3d9 --- /dev/null +++ b/frontend/lib/services/openflare/base.service.ts @@ -0,0 +1,5 @@ +import { BaseService } from '@/lib/services/core'; + +export class OpenFlareBaseService extends BaseService { + protected static override readonly basePath: string = '/api/v1/d'; +} diff --git a/frontend/lib/services/openflare/cloudflare.service.ts b/frontend/lib/services/openflare/cloudflare.service.ts new file mode 100644 index 00000000..29fa7bbb --- /dev/null +++ b/frontend/lib/services/openflare/cloudflare.service.ts @@ -0,0 +1,105 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + CloudflareAvailableDomain, + CloudflareConnection, + CloudflareConnectionPayload, + CloudflareGroup, + CloudflareGroupDetail, + CloudflareGroupPayload, + CloudflareMember, + CloudflareMemberCreatePayload, + CloudflareOverview, + CloudflareSyncReceipt, +} from './types'; + +export const cloudflareQueryKey = ['openflare', 'cloudflare'] as const; + +export class CloudflareService extends OpenFlareBaseService { + protected static override readonly basePath = '/api/v1/d/cloudflare'; + + static getConnection(): Promise { + return this.get('/connection'); + } + + static saveConnection( + payload: CloudflareConnectionPayload, + ): Promise { + return this.put('/connection', payload); + } + + static verifyConnection(): Promise { + return this.post('/connection/verify'); + } + + static clearConnection(): Promise { + return this.post('/connection/clear'); + } + + static getOverview(): Promise { + return this.get('/overview'); + } + + static listGroups(): Promise { + return this.get('/groups'); + } + + static getGroup(id: number): Promise { + return this.get(`/groups/${id}`); + } + + static createGroup( + payload: CloudflareGroupPayload, + ): Promise { + return this.post('/groups', payload); + } + + static updateGroup( + id: number, + payload: CloudflareGroupPayload, + ): Promise { + return this.post(`/groups/${id}/update`, payload); + } + + static deleteGroup(id: number): Promise { + return this.post(`/groups/${id}/delete`); + } + + static syncGroup(id: number): Promise { + return this.post(`/groups/${id}/sync`); + } + + static listAvailableDomains(): Promise { + return this.get('/domains/available'); + } + + static createMember( + groupId: number, + payload: CloudflareMemberCreatePayload, + ): Promise { + return this.post(`/groups/${groupId}/members`, payload); + } + + static updateMember( + groupId: number, + memberId: number, + proxied: boolean, + ): Promise { + return this.post( + `/groups/${groupId}/members/${memberId}/update`, + { proxied }, + ); + } + + static removeMember(groupId: number, memberId: number): Promise { + return this.post(`/groups/${groupId}/members/${memberId}/remove`); + } + + static syncMember( + groupId: number, + memberId: number, + ): Promise { + return this.post( + `/groups/${groupId}/members/${memberId}/sync`, + ); + } +} diff --git a/frontend/lib/services/openflare/config-version.service.ts b/frontend/lib/services/openflare/config-version.service.ts new file mode 100644 index 00000000..60035cf7 --- /dev/null +++ b/frontend/lib/services/openflare/config-version.service.ts @@ -0,0 +1,56 @@ +import type { InternalAxiosRequestConfig } from 'axios'; + +import { OpenFlareBaseService } from './base.service'; +import type { + ConfigDiffResult, + ConfigPreviewResult, + ConfigVersionCleanupPayload, + ConfigVersionCleanupResult, + ConfigVersionDetail, + ConfigVersionSummary, +} from './types'; + +export class ConfigVersionService extends OpenFlareBaseService { + protected static override readonly basePath: string = + '/api/v1/d/config-versions'; + + static list(): Promise { + return this.get('/'); + } + + static getActive(): Promise { + return this.get('/active'); + } + + static preview(): Promise { + return this.get('/preview'); + } + + static diff(): Promise { + return this.get('/diff'); + } + + static getById(id: string): Promise { + return this.get(`/${id}`); + } + + static publish(force?: boolean): Promise { + return this.post( + '/publish', + undefined, + force + ? ({ params: { force: true } } as InternalAxiosRequestConfig) + : undefined, + ); + } + + static activate(id: string): Promise { + return this.post(`/${id}/activate`); + } + + static cleanup( + payload: ConfigVersionCleanupPayload, + ): Promise { + return this.post('/cleanup', payload); + } +} diff --git a/frontend/lib/services/openflare/dashboard.service.ts b/frontend/lib/services/openflare/dashboard.service.ts new file mode 100644 index 00000000..391c5a10 --- /dev/null +++ b/frontend/lib/services/openflare/dashboard.service.ts @@ -0,0 +1,251 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + CompactCapacityTrendPoint, + CompactDashboardNodeHealth, + CompactDiskIOTrendPoint, + CompactDistributionItem, + CompactNetworkTrendPoint, + CompactTrafficTrendPoint, + DashboardCapacity, + DashboardNodeHealth, + DashboardOverview, + DashboardOverviewCompact, + DashboardSummary, + DashboardTraffic, + DistributionItem, +} from './types'; + +function arrayOrEmpty(value: T[] | null | undefined) { + return Array.isArray(value) ? value : []; +} + +function isCompactDistributionItem( + value: DistributionItem | CompactDistributionItem, +): value is CompactDistributionItem { + return Array.isArray(value); +} + +function isCompactTrafficTrendPoint( + value: + | DashboardOverview['trends']['traffic_24h'][number] + | CompactTrafficTrendPoint, +): value is CompactTrafficTrendPoint { + return Array.isArray(value); +} + +function isCompactCapacityTrendPoint( + value: + | DashboardOverview['trends']['capacity_24h'][number] + | CompactCapacityTrendPoint, +): value is CompactCapacityTrendPoint { + return Array.isArray(value); +} + +function isCompactNetworkTrendPoint( + value: + | DashboardOverview['trends']['network_24h'][number] + | CompactNetworkTrendPoint, +): value is CompactNetworkTrendPoint { + return Array.isArray(value); +} + +function isCompactDiskIOTrendPoint( + value: + | DashboardOverview['trends']['disk_io_24h'][number] + | CompactDiskIOTrendPoint, +): value is CompactDiskIOTrendPoint { + return Array.isArray(value); +} + +function isCompactDashboardNode( + value: DashboardNodeHealth | CompactDashboardNodeHealth, +): value is CompactDashboardNodeHealth { + return Array.isArray(value); +} + +function normalizeDistributionItems( + items: Array | null | undefined, +): DistributionItem[] { + return arrayOrEmpty(items).map((item) => + isCompactDistributionItem(item) + ? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) } + : item, + ); +} + +function normalizeTrafficTrendPoints( + items: + | Array< + | DashboardOverview['trends']['traffic_24h'][number] + | CompactTrafficTrendPoint + > + | null + | undefined, +) { + return arrayOrEmpty(items).map((item) => + isCompactTrafficTrendPoint(item) + ? { + bucket_started_at: String(item[0] ?? ''), + request_count: Number(item[1] ?? 0), + error_count: Number(item[2] ?? 0), + unique_visitor_count: Number(item[3] ?? 0), + status_2xx_count: Number(item[4] ?? 0), + status_4xx_count: Number(item[5] ?? 0), + status_5xx_count: Number(item[6] ?? 0), + } + : item, + ); +} + +function normalizeCapacityTrendPoints( + items: + | Array< + | DashboardOverview['trends']['capacity_24h'][number] + | CompactCapacityTrendPoint + > + | null + | undefined, +) { + return arrayOrEmpty(items).map((item) => + isCompactCapacityTrendPoint(item) + ? { + bucket_started_at: String(item[0] ?? ''), + average_cpu_usage_percent: Number(item[1] ?? 0), + average_memory_usage_percent: Number(item[2] ?? 0), + reported_nodes: Number(item[3] ?? 0), + } + : item, + ); +} + +function normalizeNetworkTrendPoints( + items: + | Array< + | DashboardOverview['trends']['network_24h'][number] + | CompactNetworkTrendPoint + > + | null + | undefined, +) { + return arrayOrEmpty(items).map((item) => + isCompactNetworkTrendPoint(item) + ? { + bucket_started_at: String(item[0] ?? ''), + bytes_received: Number(item[1] ?? 0), + bytes_provided: Number(item[2] ?? 0), + reported_nodes: Number(item[3] ?? 0), + } + : { + ...item, + bytes_received: Number( + (item as { bytes_received?: number }).bytes_received ?? 0, + ), + bytes_provided: Number( + (item as { bytes_provided?: number }).bytes_provided ?? 0, + ), + }, + ); +} + +function normalizeDiskIOTrendPoints( + items: + | Array< + | DashboardOverview['trends']['disk_io_24h'][number] + | CompactDiskIOTrendPoint + > + | null + | undefined, +) { + return arrayOrEmpty(items).map((item) => + isCompactDiskIOTrendPoint(item) + ? { + bucket_started_at: String(item[0] ?? ''), + disk_read_bytes: Number(item[1] ?? 0), + disk_write_bytes: Number(item[2] ?? 0), + reported_nodes: Number(item[3] ?? 0), + } + : item, + ); +} + +function normalizeDashboardNodes( + items: + | Array + | null + | undefined, +): DashboardNodeHealth[] { + return arrayOrEmpty(items).map((item) => + isCompactDashboardNode(item) + ? { + id: Number(item[0] ?? 0), + node_id: String(item[1] ?? ''), + name: String(item[2] ?? ''), + geo_name: String(item[3] ?? ''), + geo_latitude: + item[4] === null || item[4] === undefined ? null : Number(item[4]), + geo_longitude: + item[5] === null || item[5] === undefined ? null : Number(item[5]), + status: (item[6] ?? 'pending') as DashboardNodeHealth['status'], + openresty_status: (item[7] ?? + 'unknown') as DashboardNodeHealth['openresty_status'], + current_version: String(item[8] ?? ''), + last_seen_at: String(item[9] ?? ''), + active_event_count: Number(item[10] ?? 0), + cpu_usage_percent: Number(item[11] ?? 0), + memory_usage_percent: Number(item[12] ?? 0), + storage_usage_percent: Number(item[13] ?? 0), + request_count: Number(item[14] ?? 0), + error_count: Number(item[15] ?? 0), + unique_visitor_count: Number(item[16] ?? 0), + } + : item, + ); +} + +function normalizeDashboardOverview( + overview: DashboardOverview | DashboardOverviewCompact | null | undefined, +): DashboardOverview | null { + if (!overview) { + return null; + } + + const summary = (overview.summary ?? {}) as DashboardSummary; + const traffic = (overview.traffic ?? {}) as DashboardTraffic; + const capacity = (overview.capacity ?? {}) as DashboardCapacity; + + return { + generated_at: String(overview.generated_at ?? ''), + summary, + traffic, + capacity, + nodes: normalizeDashboardNodes(overview.nodes), + distributions: { + source_countries: normalizeDistributionItems( + overview.distributions?.source_countries, + ), + status_codes: normalizeDistributionItems( + overview.distributions?.status_codes, + ), + top_domains: normalizeDistributionItems( + overview.distributions?.top_domains, + ), + }, + trends: { + traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h), + capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h), + network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h), + disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h), + }, + }; +} + +export class DashboardService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/dashboard'; + + static async getOverview(): Promise { + const overview = await this.get< + DashboardOverview | DashboardOverviewCompact + >('/overview'); + return normalizeDashboardOverview(overview); + } +} diff --git a/frontend/lib/services/openflare/dns-account.service.ts b/frontend/lib/services/openflare/dns-account.service.ts new file mode 100644 index 00000000..eed2a7f2 --- /dev/null +++ b/frontend/lib/services/openflare/dns-account.service.ts @@ -0,0 +1,28 @@ +import { OpenFlareBaseService } from './base.service'; +import type { DnsAccountItem, DnsAccountMutationPayload } from './types'; + +export class DnsAccountService extends OpenFlareBaseService { + protected static override readonly basePath: string = + '/api/v1/d/dns-accounts'; + + static async list(): Promise { + return this.get('/'); + } + + static async create( + payload: DnsAccountMutationPayload, + ): Promise { + return this.post('/', payload); + } + + static async update( + id: number, + payload: DnsAccountMutationPayload, + ): Promise { + return this.post(`/${id}/update`, payload); + } + + static async deleteById(id: number): Promise { + return this.post(`/${id}/delete`); + } +} diff --git a/frontend/lib/services/openflare/index.ts b/frontend/lib/services/openflare/index.ts new file mode 100644 index 00000000..cf1a249b --- /dev/null +++ b/frontend/lib/services/openflare/index.ts @@ -0,0 +1,197 @@ +export { OpenFlareBaseService } from './base.service'; + +export { NodeService } from './node.service'; +export { ProxyRouteService } from './proxy-route.service'; +export { ConfigVersionService } from './config-version.service'; +export { ApplyLogService } from './apply-log.service'; +export { DashboardService } from './dashboard.service'; +export { WafService } from './waf.service'; +export { ZoneDomainService, ZoneService, zoneQueryKey } from './zone.service'; +export { TlsCertificateService } from './tls-certificate.service'; +export { DnsAccountService } from './dns-account.service'; +export { PagesService } from './pages.service'; +export { OriginService } from './origin.service'; +export { AccessLogService } from './access-log.service'; +export { OptionService } from './option.service'; +export { UptimeKumaService } from './uptimekuma.service'; +export { StatusService } from './status.service'; +export { + CloudflareService, + cloudflareQueryKey, +} from './cloudflare.service'; + +export type { + ApplyLogCleanupPayload, + ApplyLogCleanupResult, + ApplyLogItem, + ApplyLogList, + ApplyLogListQuery, + ApplyResult, + ConfigDiffResult, + ConfigOptionDiffItem, + ConfigPreviewResult, + ConfigVersionCleanupPayload, + ConfigVersionCleanupResult, + ConfigVersionDetail, + ConfigVersionSummary, + NodeAgentReleaseInfo, + NodeAgentUpdatePayload, + NodeBootstrapToken, + NodeItem, + NodeMutationPayload, + NodeHealthEvent, + NodeObservability, + NodeObservabilityAnalytics, + NodeObservabilityTrends, + NodeSystemProfile, + NodeStatus, + NodeType, + OpenrestyStatus, + ProxyRouteConfigSection, + ProxyRouteCustomHeader, + ProxyRouteItem, + ProxyRouteMutationPayload, + ProxyRoutePoWConfig, + ProxyRouteZoneDomain, + ReleaseChannel, + SupportFile, + DashboardOverview, + DashboardNodeHealth, + DashboardSummary, + DashboardTraffic, + DashboardCapacity, + DistributionItem, + TrafficDistributions, + TrafficTrendPoint, + CapacityTrendPoint, + NetworkTrendPoint, + DiskIOTrendPoint, + WAFIPGroup, + WAFIPGroupAutoTestPayload, + WAFIPGroupAutoTestResult, + WAFIPGroupPayload, + WAFIPGroupSyncResult, + WAFIPGroupSubscriptionFormat, + WAFIPGroupType, + WAFCreateRulePayload, + WAFRule, + WAFRuleEdge, + WAFRuleGraph, + WAFRuleNode, + WAFSaveRuleGraphPayload, + WAFUpdateRuleMetaPayload, + WAFRuleGroup, + WAFRuleGroupPayload, + WAFSiteRuleGroups, + AccessLogCleanupPayload, + AccessLogCleanupResult, + AccessLogFilters, + AccessLogIPAnalysis, + AccessLogIPAnalysisFilters, + AccessLogIPSummaryFilters, + AccessLogIPSummaryItem, + AccessLogIPSummaryList, + AccessLogIPTrend, + AccessLogIPTrendFilters, + AccessLogItem, + AccessLogList, + AccessLogOverview, + AccessLogOverviewFilters, + AccessLogOverviewMetricPoint, + FoldedAccessLogFilters, + FoldedAccessLogIPFilters, + FoldedAccessLogIPList, + FoldedAccessLogList, + OptionItem, + GeoIPLookupResult, + OpenFlarePublicStatus, + OriginDetail, + OriginItem, + OriginMutationPayload, + PagesDeployment, + PagesDeploymentFile, + PagesGitHubLatestSourceUpdatePayload, + PagesGitHubReleaseSelector, + PagesGitHubReleaseSource, + PagesGitHubSourceUpdatePayload, + PagesGitHubTagSourceUpdatePayload, + PagesManualSource, + PagesProject, + PagesProjectPayload, + PagesRemoteSourceUpdatePayload, + PagesRemoteURLSource, + PagesSource, + PagesSourceActionPayload, + PagesSourceActionReceipt, + PagesSourceRevision, + PagesSourceStatus, + PagesSourceUpdatePayload, + PagesSourceUpdateResult, + AcmeAccountItem, + DnsAccountItem, + DnsAccountMutationPayload, + ZoneDomainItem, + ZoneDomainMutationPayload, + ZoneItem, + ZoneMutationPayload, + ZoneOverview, + ZoneStats, + ZoneStatsPoint, + ZoneStatsRange, + TlsCertificateApplyPayload, + TlsCertificateContentItem, + TlsCertificateDetailItem, + TlsCertificateFileImportPayload, + TlsCertificateItem, + TlsCertificateMutationPayload, + CloudflareAvailableDomain, + CloudflareConnection, + CloudflareConnectionPayload, + CloudflareConnectionSource, + CloudflareGroup, + CloudflareGroupDetail, + CloudflareGroupPayload, + CloudflareMember, + CloudflareMemberCreatePayload, + CloudflareNodeOption, + CloudflareOverview, + CloudflareSyncReceipt, + CloudflareSyncStatus, +} from './types'; + +import { AccessLogService } from './access-log.service'; +import { ApplyLogService } from './apply-log.service'; +import { ConfigVersionService } from './config-version.service'; +import { DashboardService } from './dashboard.service'; +import { DnsAccountService } from './dns-account.service'; +import { NodeService } from './node.service'; +import { OptionService } from './option.service'; +import { StatusService } from './status.service'; +import { UptimeKumaService } from './uptimekuma.service'; +import { OriginService } from './origin.service'; +import { PagesService } from './pages.service'; +import { ProxyRouteService } from './proxy-route.service'; +import { TlsCertificateService } from './tls-certificate.service'; +import { WafService } from './waf.service'; +import { ZoneDomainService, ZoneService } from './zone.service'; +import { CloudflareService } from './cloudflare.service'; + +export const openflareServices = { + node: NodeService, + proxyRoute: ProxyRouteService, + configVersion: ConfigVersionService, + applyLog: ApplyLogService, + dashboard: DashboardService, + waf: WafService, + zone: ZoneService, + zoneDomain: ZoneDomainService, + tlsCertificate: TlsCertificateService, + dnsAccount: DnsAccountService, + pages: PagesService, + origin: OriginService, + accessLog: AccessLogService, + option: OptionService, + uptimeKuma: UptimeKumaService, + status: StatusService, + cloudflare: CloudflareService, +} as const; diff --git a/frontend/lib/services/openflare/node.service.ts b/frontend/lib/services/openflare/node.service.ts new file mode 100644 index 00000000..64a254d0 --- /dev/null +++ b/frontend/lib/services/openflare/node.service.ts @@ -0,0 +1,81 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + NodeAgentReleaseInfo, + NodeAgentUpdatePayload, + NodeBootstrapToken, + NodeItem, + NodeMutationPayload, + NodeObservability, + ReleaseChannel, +} from './types'; + +export class NodeService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/nodes'; + + static async listNodes(): Promise { + return this.get('/'); + } + + static async createNode(payload: NodeMutationPayload): Promise { + return this.post('/', payload); + } + + static async updateNode( + id: number, + payload: NodeMutationPayload, + ): Promise { + return this.post(`/${id}/update`, payload); + } + + static async deleteNode(id: number): Promise { + return this.post(`/${id}/delete`); + } + + static async getBootstrapToken(): Promise { + return this.get('/bootstrap-token'); + } + + static async rotateBootstrapToken(): Promise { + return this.post('/bootstrap-token/rotate'); + } + + static async requestAgentUpdate( + id: number, + payload?: NodeAgentUpdatePayload, + ): Promise { + return this.post(`/${id}/agent-update`, payload ?? {}); + } + + static async requestForceSync(id: number): Promise { + return this.post(`/${id}/force-sync`); + } + + static async requestOpenrestyRestart(id: number): Promise { + return this.post(`/${id}/openresty-restart`); + } + + static async getAgentRelease( + id: number, + channel: ReleaseChannel = 'stable', + ): Promise { + return this.get(`/${id}/agent-release`, { channel }); + } + + static async getObservability( + id: number, + options?: { hours?: number; limit?: number }, + ): Promise { + return this.get(`/${id}/observability`, { + hours: options?.hours, + limit: options?.limit, + }); + } + + static async cleanupHealthEvents( + id: number, + ): Promise<{ node_id: string; deleted_count: number }> { + return this.post<{ node_id: string; deleted_count: number }>( + `/${id}/observability/cleanup`, + ); + } +} diff --git a/frontend/lib/services/openflare/option.service.ts b/frontend/lib/services/openflare/option.service.ts new file mode 100644 index 00000000..3865172e --- /dev/null +++ b/frontend/lib/services/openflare/option.service.ts @@ -0,0 +1,27 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + GeoIPLookupResult, + OptionBatchPayload, + OptionItem, +} from './types'; + +export class OptionService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/option'; + + static list(): Promise { + return this.get('/'); + } + + static update(key: string, value: string): Promise { + return this.post('/update', { key, value }); + } + + static updateBatch(options: OptionItem[]): Promise { + const payload: OptionBatchPayload = { options }; + return this.post('/update-batch', payload); + } + + static lookupGeoIP(provider: string, ip: string): Promise { + return this.post('/geoip/lookup', { provider, ip }); + } +} diff --git a/frontend/lib/services/openflare/origin.service.ts b/frontend/lib/services/openflare/origin.service.ts new file mode 100644 index 00000000..b0b7d6d2 --- /dev/null +++ b/frontend/lib/services/openflare/origin.service.ts @@ -0,0 +1,29 @@ +import { OpenFlareBaseService } from './base.service'; +import type { OriginDetail, OriginItem, OriginMutationPayload } from './types'; + +export class OriginService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/origins'; + + static list(): Promise { + return this.get('/'); + } + + static getById(id: number): Promise { + return this.get(`/${id}`); + } + + static create(payload: OriginMutationPayload): Promise { + return this.post('/', payload); + } + + static update( + id: number, + payload: OriginMutationPayload, + ): Promise { + return this.post(`/${id}/update`, payload); + } + + static deleteById(id: number): Promise { + return this.post(`/${id}/delete`); + } +} diff --git a/frontend/lib/services/openflare/pages.service.ts b/frontend/lib/services/openflare/pages.service.ts new file mode 100644 index 00000000..a666ef6b --- /dev/null +++ b/frontend/lib/services/openflare/pages.service.ts @@ -0,0 +1,157 @@ +import type { AxiosProgressEvent, InternalAxiosRequestConfig } from 'axios'; + +import apiClient from '@/lib/services/core/api-client'; +import { apiConfig } from '@/lib/services/core/config'; +import type { ApiResponse } from '@/lib/services/core'; + +import { OpenFlareBaseService } from './base.service'; +import type { + PagesDeployment, + PagesDeploymentFile, + PagesDeploymentUploadFromURLPayload, + PagesDeploymentUploadPayload, + PagesProject, + PagesProjectPayload, + PagesSource, + PagesSourceActionPayload, + PagesSourceActionReceipt, + PagesSourceUpdatePayload, + PagesSourceUpdateResult, +} from './types'; + +export class PagesService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/pages'; + + static listProjects(): Promise { + return this.get('/'); + } + + static getProject(id: number): Promise { + return this.get(`/${id}`); + } + + static createProject(payload: PagesProjectPayload): Promise { + return this.post('/', payload); + } + + static updateProject( + id: number, + payload: PagesProjectPayload, + ): Promise { + return this.post(`/${id}/update`, payload); + } + + static deleteProject(id: number): Promise { + return this.post(`/${id}/delete`); + } + + static getSource(projectId: number): Promise { + return this.get(`/${projectId}/source`); + } + + static updateSource( + projectId: number, + payload: PagesSourceUpdatePayload, + ): Promise { + return this.post( + `/${projectId}/source/update`, + payload, + ); + } + + static deleteSource(projectId: number): Promise { + return this.post(`/${projectId}/source/delete`); + } + + static checkSource(projectId: number): Promise { + return this.post( + `/${projectId}/source/check`, + {}, + ); + } + + static syncSource( + projectId: number, + payload: PagesSourceActionPayload = {}, + ): Promise { + return this.post( + `/${projectId}/source/sync`, + payload, + ); + } + + static listDeployments(projectId: number): Promise { + return this.get(`/${projectId}/deployments`); + } + + static listDeploymentFiles( + deploymentId: number, + ): Promise { + return this.get( + `/deployments/${deploymentId}/files`, + ); + } + + static uploadDeployment( + projectId: number, + payload: PagesDeploymentUploadPayload, + ): Promise { + const formData = new FormData(); + formData.append('package', payload.file); + + return this.postFormData( + `/${projectId}/deployments/upload`, + formData, + payload.onProgress, + ); + } + + static uploadDeploymentFromURL( + projectId: number, + payload: PagesDeploymentUploadFromURLPayload, + ): Promise { + return this.post( + `/${projectId}/deployments/upload-from-url`, + payload, + { timeout: apiConfig.uploadTimeout } as InternalAxiosRequestConfig, + ); + } + + static activateDeployment( + projectId: number, + deploymentId: number, + ): Promise { + return this.post( + `/${projectId}/deployments/${deploymentId}/activate`, + ); + } + + static deleteDeployment( + projectId: number, + deploymentId: number, + ): Promise { + return this.post(`/${projectId}/deployments/${deploymentId}/delete`); + } + + private static async postFormData( + path: string, + formData: FormData, + onProgress?: (percent: number) => void, + ): Promise { + const response = await apiClient.post>( + this.getFullPath(path), + formData, + { + timeout: apiConfig.uploadTimeout, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (event: AxiosProgressEvent) => { + if (!onProgress || !event.total) return; + const percent = Math.round((event.loaded / event.total) * 100); + onProgress(percent); + }, + } as InternalAxiosRequestConfig, + ); + + return response.data.data; + } +} diff --git a/frontend/lib/services/openflare/proxy-route.service.ts b/frontend/lib/services/openflare/proxy-route.service.ts new file mode 100644 index 00000000..2e235e19 --- /dev/null +++ b/frontend/lib/services/openflare/proxy-route.service.ts @@ -0,0 +1,32 @@ +import { OpenFlareBaseService } from './base.service'; +import type { ProxyRouteItem, ProxyRouteMutationPayload } from './types'; + +export class ProxyRouteService extends OpenFlareBaseService { + protected static override readonly basePath: string = + '/api/v1/d/proxy-routes'; + + static async list(): Promise { + return this.get('/'); + } + + static async getById(id: number): Promise { + return this.get(`/${id}`); + } + + static async create( + payload: ProxyRouteMutationPayload, + ): Promise { + return this.post('/', payload); + } + + static async update( + id: number, + payload: ProxyRouteMutationPayload, + ): Promise { + return this.post(`/${id}/update`, payload); + } + + static async deleteById(id: number): Promise { + return this.post(`/${id}/delete`); + } +} diff --git a/frontend/lib/services/openflare/status.service.ts b/frontend/lib/services/openflare/status.service.ts new file mode 100644 index 00000000..2e8d6413 --- /dev/null +++ b/frontend/lib/services/openflare/status.service.ts @@ -0,0 +1,8 @@ +import { OpenFlareBaseService } from './base.service'; +import type { OpenFlarePublicStatus } from './types'; + +export class StatusService extends OpenFlareBaseService { + static getPublicStatus(): Promise { + return this.get('/status'); + } +} diff --git a/frontend/lib/services/openflare/tls-certificate.service.ts b/frontend/lib/services/openflare/tls-certificate.service.ts new file mode 100644 index 00000000..e0451c4f --- /dev/null +++ b/frontend/lib/services/openflare/tls-certificate.service.ts @@ -0,0 +1,93 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + AcmeAccountItem, + TlsCertificateApplyPayload, + TlsCertificateContentItem, + TlsCertificateDetailItem, + TlsCertificateFileImportPayload, + TlsCertificateItem, + TlsCertificateMutationPayload, +} from './types'; + +class AcmeAccountApi extends OpenFlareBaseService { + protected static override readonly basePath: string = + '/api/v1/d/acme-accounts'; + + static getDefault(): Promise { + return this.get('/default'); + } +} + +export class TlsCertificateService extends OpenFlareBaseService { + protected static override readonly basePath: string = + '/api/v1/d/tls-certificates'; + + static async list(): Promise { + return this.get('/'); + } + + static async getById(id: number): Promise { + return this.get(`/${id}`); + } + + static async getContent(id: number): Promise { + return this.get(`/${id}/content`); + } + + static async create( + payload: TlsCertificateMutationPayload, + ): Promise { + return this.post('/', payload); + } + + static async update( + id: number, + payload: TlsCertificateMutationPayload, + ): Promise { + return this.post(`/${id}/update`, payload); + } + + static async deleteById(id: number): Promise { + return this.post(`/${id}/delete`); + } + + static async apply( + payload: TlsCertificateApplyPayload, + ): Promise { + return this.post('/apply', payload); + } + + static async renew(id: number): Promise { + return this.post(`/${id}/renew`); + } + + static async updateAcme( + id: number, + payload: TlsCertificateApplyPayload, + ): Promise { + return this.post(`/${id}/update-acme`, payload); + } + + static async convertToAcme( + id: number, + payload: TlsCertificateApplyPayload, + ): Promise { + return this.post(`/${id}/convert-acme`, payload); + } + + static async importFile( + payload: TlsCertificateFileImportPayload, + ): Promise { + const formData = new FormData(); + formData.append('name', payload.name); + formData.append('remark', payload.remark); + formData.append('cert_file', payload.certFile); + formData.append('key_file', payload.keyFile); + + return this.post('/import-file', formData); + } + + static getDefaultAcmeAccount(): Promise { + return AcmeAccountApi.getDefault(); + } +} diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts new file mode 100644 index 00000000..2395a343 --- /dev/null +++ b/frontend/lib/services/openflare/types.ts @@ -0,0 +1,1516 @@ +import type { XYPosition } from '@xyflow/react'; + +export type ReleaseChannel = 'stable' | 'preview'; + +export type NodeType = 'edge_node' | 'tunnel_relay' | 'tunnel_client'; + +export type NodeStatus = 'online' | 'offline' | 'pending'; + +export type OpenrestyStatus = 'healthy' | 'unhealthy' | 'unknown'; + +export type ApplyResult = 'success' | 'warning' | 'failed' | ''; + +export interface NodeItem { + id: number; + node_id: string; + node_type: NodeType; + name: string; + ip: string; + ip_manual_override: boolean; + relay_bind_port: number; + relay_vhost_http_port: number; + relay_client_access_addr: string; + relay_agent_access_addr: string; + relay_client_proxy_url: string; + relay_auth_token: string; + relay_status: string; + relay_web_server_enabled: boolean; + relay_frps_connections: number; + relay_frps_proxy_count: number; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; + geo_manual_override: boolean; + access_token: string; + auto_update_enabled: boolean; + update_requested: boolean; + update_channel: ReleaseChannel; + update_tag: string; + restart_openresty_requested: boolean; + version: string; + ext_version: string; + openresty_status: OpenrestyStatus; + openresty_message: string; + status: NodeStatus; + current_version: string; + last_seen_at: string; + last_error: string; + latest_apply_result: ApplyResult; + latest_apply_message: string; + latest_apply_checksum: string; + latest_main_config_checksum: string; + latest_route_config_checksum: string; + latest_support_file_count: number; + latest_apply_at?: string | null; + created_at: string; + updated_at: string; +} + +export interface NodeBootstrapToken { + discovery_token: string; +} + +export interface NodeMutationPayload { + node_type: NodeType; + name: string; + ip: string; + ip_manual_override: boolean; + relay_bind_port?: number; + relay_vhost_http_port?: number; + relay_client_access_addr?: string; + relay_agent_access_addr?: string; + relay_client_proxy_url?: string; + relay_web_server_enabled?: boolean; + auto_update_enabled: boolean; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; + geo_manual_override: boolean; +} + +export interface NodeAgentReleaseInfo { + tag_name: string; + body: string; + html_url: string; + published_at: string; + current_version: string; + has_update: boolean; + channel: ReleaseChannel; + prerelease: boolean; + update_requested: boolean; + requested_channel: ReleaseChannel; + requested_tag: string; +} + +export interface NodeAgentUpdatePayload { + channel?: ReleaseChannel; + tag_name?: string; +} + +export interface NodeSystemProfile { + hostname: string; + os_name: string; + os_version: string; + kernel_version: string; + architecture: string; + cpu_model: string; + cpu_cores: number; + total_memory_bytes: number; + total_disk_bytes: number; + uptime_seconds: number; + reported_at: string; +} + +export interface NodeMetricSnapshot { + captured_at: string; + cpu_usage_percent: number; + memory_used_bytes: number; + memory_total_bytes: number; + storage_used_bytes: number; + storage_total_bytes: number; + disk_read_bytes: number; + disk_write_bytes: number; + openresty_connections: number; +} + +export interface NodeHealthEvent { + event_type: string; + severity: string; + status: string; + message: string; + metadata_json?: string; + first_triggered_at: string; + last_triggered_at: string; + reported_at: string; + resolved_at?: string | null; +} + +export interface NodeTrafficSummary { + window_started_at: string; + window_ended_at: string; + request_count: number; + unique_visitor_count: number; + error_count: number; + estimated_qps: number; + error_rate_percent: number; +} + +export interface NodeHealthSummary { + active_alerts: number; + critical_alerts: number; + warning_alerts: number; + info_alerts: number; + resolved_alerts: number; + has_capacity_risk: boolean; + has_traffic_risk: boolean; + has_runtime_risk: boolean; +} + +export interface NodeTrafficDistributions { + status_codes: DistributionItem[]; + top_domains: DistributionItem[]; + source_countries: DistributionItem[]; +} + +export interface NodeObservabilityAnalytics { + traffic: NodeTrafficSummary | null; + distributions: NodeTrafficDistributions; + health: NodeHealthSummary | null; +} + +export interface NodeObservabilityTrends { + traffic_24h: TrafficTrendPoint[]; + capacity_24h: CapacityTrendPoint[]; + network_24h: NetworkTrendPoint[]; + disk_io_24h: DiskIOTrendPoint[]; +} + +export interface NodeObservability { + node_id: string; + profile: NodeSystemProfile | null; + metric_snapshots: NodeMetricSnapshot[]; + health_events: NodeHealthEvent[]; + analytics?: NodeObservabilityAnalytics; + trends?: NodeObservabilityTrends; +} + +export type ProxyRouteConfigSection = + | 'domains' + | 'limits' + | 'proxy' + | 'cache' + | 'waf' + | 'auth'; + +export interface ProxyRouteCustomHeader { + key: string; + value: string; +} + +export interface ProxyRoutePoWListConfig { + ips: string[]; + ip_cidrs: string[]; + paths: string[]; + path_regexes: string[]; + user_agents: string[]; +} + +export interface ProxyRoutePoWConfig { + difficulty: number; + algorithm: 'fast' | 'slow'; + session_ttl: number; + challenge_ttl: number; + whitelist: ProxyRoutePoWListConfig; + blacklist: ProxyRoutePoWListConfig; +} + +/** Route-bound Zone domain as returned by proxy-route APIs. */ +export interface ProxyRouteZoneDomain { + id: number; + zone_id: number; + domain: string; + cert_id: number | null; +} + +export interface ProxyRouteItem { + id: number; + site_name: string; + zone_domain_ids: number[]; + zone_domains: ProxyRouteZoneDomain[]; + origin_id: number | null; + origin_url: string; + origin_host: string; + upstreams: string; + upstream_list: string[]; + enabled: boolean; + enable_https: boolean; + redirect_http: boolean; + limit_conn_per_server: number; + limit_conn_per_ip: number; + limit_rate: string; + limit_req_per_ip: string; + cache_enabled: boolean; + cache_policy: string; + cache_rules: string; + cache_rule_list: string[]; + custom_headers: string; + custom_header_list: ProxyRouteCustomHeader[]; + basic_auth_enabled: boolean; + basic_auth_username: string; + basic_auth_password: string; + upstream_type: 'direct' | 'tunnel' | 'pages'; + tunnel_node_id?: number | null; + tunnel_id?: number | null; + tunnel_target_addr?: string; + tunnel_target_protocol?: string; + pages_project_id?: number | null; + created_at: string; + updated_at: string; +} + +export interface ProxyRouteMutationPayload { + site_name?: string; + zone_domain_ids: number[]; + origin_id: number | null; + origin_url: string; + origin_scheme: 'http' | 'https'; + origin_address: string; + origin_port: string; + origin_uri: string; + origin_host: string; + upstreams: string[]; + enabled: boolean; + enable_https: boolean; + redirect_http: boolean; + limit_conn_per_server?: number; + limit_conn_per_ip?: number; + limit_rate?: string; + limit_req_per_ip?: string; + cache_enabled: boolean; + cache_policy: string; + cache_rules: string[]; + custom_headers: ProxyRouteCustomHeader[]; + basic_auth_enabled: boolean; + basic_auth_username?: string; + basic_auth_password?: string; + upstream_type?: 'direct' | 'tunnel' | 'pages'; + tunnel_node_id?: number | null; + tunnel_id?: number | null; + tunnel_target_addr?: string; + tunnel_target_protocol?: string; + pages_project_id?: number | null; +} + +export interface ConfigVersionSummary { + id: string; + version: string; + checksum: string; + is_active: boolean; + created_by: string; + created_at: string; +} + +export interface ConfigVersionDetail extends ConfigVersionSummary { + snapshot_json: string; + main_config: string; + rendered_config: string; + support_files_json: string; +} + +export interface SupportFile { + path: string; + content: string; +} + +export interface ConfigPreviewResult { + snapshot_json: string; + main_config: string; + route_config: string; + rendered_config: string; + support_files: SupportFile[]; + checksum: string; + route_count: number; + website_count: number; +} + +export interface ConfigOptionDiffItem { + key: string; + previous_value: string; + current_value: string; +} + +export interface ConfigDiffResult { + active_version?: string; + added_sites: string[]; + removed_sites: string[]; + modified_sites: string[]; + added_domains: string[]; + removed_domains: string[]; + modified_domains: string[]; + main_config_changed: boolean; + waf_config_changed: boolean; + changed_option_keys: string[]; + changed_option_details: ConfigOptionDiffItem[]; + current_website_count: number; + active_website_count: number; +} + +export interface ConfigVersionCleanupPayload { + keep_count: number; +} + +export interface ConfigVersionCleanupResult { + deleted_count: number; +} + +export interface ApplyLogItem { + id: number; + node_id: string; + version: string; + result: 'success' | 'failed' | string; + message: string; + checksum: string; + main_config_checksum: string; + route_config_checksum: string; + support_file_count: number; + created_at: string; +} + +export interface ApplyLogList { + rows: ApplyLogItem[]; + current: number; + total: number; + totalPage: number; +} + +export interface ApplyLogListQuery { + node_id?: string; + pageNo?: number; + pageSize?: number; +} + +export interface ApplyLogCleanupPayload { + delete_all?: boolean; + retention_days?: number; +} + +export interface ApplyLogCleanupResult { + delete_all: boolean; + retention_days: number; + deleted_count: number; + cutoff?: string; +} + +// ==================== Pages ==================== + +export interface PagesDeployment { + id: number; + project_id: number; + deployment_number: number; + checksum: string; + status: 'uploaded' | 'active'; + file_count: number; + total_size: number; + created_by: string; + source_type: 'manual_upload' | 'manual_url' | 'remote_url' | 'github_release'; + source_label: string; + trigger_type: + | 'manual_upload' + | 'manual_url' + | 'manual_sync' + | 'scheduled_auto_update'; + created_at: string; + activated_at?: string | null; +} + +export interface PagesDeploymentFile { + id: number; + deployment_id: number; + path: string; + size: number; + checksum: string; + created_at: string; +} + +export interface PagesProject { + id: number; + name: string; + slug: string; + description: string; + enabled: boolean; + spa_fallback_enabled: boolean; + spa_fallback_path: string; + api_proxy_enabled: boolean; + api_proxy_path: string; + api_proxy_pass: string; + api_proxy_rewrite: string; + root_dir?: string; + entry_file: string; + active_deployment_id?: number | null; + active_deployment?: PagesDeployment | null; + deployment_count: number; + created_at: string; + updated_at: string; +} + +export interface PagesProjectPayload { + name: string; + slug: string; + description: string; + enabled: boolean; + spa_fallback_enabled: boolean; + spa_fallback_path: string; + api_proxy_enabled: boolean; + api_proxy_path: string; + api_proxy_pass: string; + api_proxy_rewrite: string; + root_dir?: string; + entry_file: string; +} + +export interface PagesDeploymentUploadPayload { + file: File; + onProgress?: (percent: number) => void; +} + +export interface PagesDeploymentUploadFromURLPayload { + url: string; +} + +export type PagesSourceStatus = + | 'idle' + | 'checking' + | 'update_available' + | 'syncing' + | 'failed' + | 'attention'; + +export type PagesGitHubReleaseSelector = 'latest' | 'tag'; + +export interface PagesSourceRevision { + revision: string; + label: string; + asset_name?: string; +} + +interface PagesSourceRuntimeView { + sync_status?: PagesSourceStatus; + update_available?: boolean; + last_seen?: PagesSourceRevision; + last_applied?: PagesSourceRevision; + last_checked_at?: string | null; + last_synced_at?: string | null; + next_check_at?: string | null; + last_error?: string; +} + +export interface PagesManualSource { + source_type: 'manual'; +} + +export interface PagesRemoteURLSource extends PagesSourceRuntimeView { + source_type: 'remote_url'; + remote_url: string; + allow_insecure?: boolean; +} + +interface PagesGitHubReleaseSourceBase extends PagesSourceRuntimeView { + source_type: 'github_release'; + github_repository: string; + asset_name: string; +} + +interface PagesGitHubLatestReleaseSource extends PagesGitHubReleaseSourceBase { + release_selector: 'latest'; + release_tag?: ''; + auto_update_enabled: boolean; + check_interval_minutes: number; +} + +interface PagesGitHubTagReleaseSource extends PagesGitHubReleaseSourceBase { + release_selector: 'tag'; + release_tag: string; + auto_update_enabled: false; + check_interval_minutes?: 0; +} + +export type PagesGitHubReleaseSource = + | PagesGitHubLatestReleaseSource + | PagesGitHubTagReleaseSource; + +/** + * 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant, + * 不需要向 Remote 或 GitHub Release 填入构建字段。 + */ +export type PagesSource = + | PagesManualSource + | PagesRemoteURLSource + | PagesGitHubReleaseSource; + +export interface PagesRemoteSourceUpdatePayload { + source_type: 'remote_url'; + remote_url: string; + allow_insecure?: boolean; +} + +interface PagesGitHubSourceUpdateBase { + source_type: 'github_release'; + repository_url: string; + asset_name: string; +} + +export interface PagesGitHubLatestSourceUpdatePayload + extends PagesGitHubSourceUpdateBase { + release_selector: 'latest'; + release_tag: ''; + auto_update_enabled: boolean; + check_interval_minutes: number; +} + +export interface PagesGitHubTagSourceUpdatePayload + extends PagesGitHubSourceUpdateBase { + release_selector: 'tag'; + release_tag: string; + auto_update_enabled: false; + check_interval_minutes: 0; +} + +export type PagesGitHubSourceUpdatePayload = + | PagesGitHubLatestSourceUpdatePayload + | PagesGitHubTagSourceUpdatePayload; + +/** + * Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant, + * 不向 Remote URL 或 GitHub Release payload 混入构建字段。 + */ +export type PagesSourceUpdatePayload = + | PagesRemoteSourceUpdatePayload + | PagesGitHubSourceUpdatePayload; + +export interface PagesSourceActionPayload { + confirmed_revision?: string; +} + +export interface PagesSourceActionReceipt { + task_id: string; + execution_id: string; + action: 'check' | 'sync'; +} + +export interface PagesSourceUpdateResult { + source: PagesSource; + check_task: PagesSourceActionReceipt | null; + warning: string; +} + +// ==================== Origins ==================== + +export interface OriginItem { + id: number; + name: string; + address: string; + remark: string; + route_count: number; + created_at: string; + updated_at: string; +} + +export interface OriginRouteSummary { + id: number; + domain: string; + origin_url: string; + enabled: boolean; + updated_at: string; +} + +export interface OriginDetail extends OriginItem { + routes: OriginRouteSummary[]; +} + +export interface OriginMutationPayload { + name: string; + address: string; + remark: string; +} + +// ==================== Access Logs ==================== + +export interface AccessLogFilters { + node_id?: string; + remote_addr?: string; + host?: string; + path?: string; + status_code?: number; + since?: string; + until?: string; + p?: number; + page_size?: number; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + +export interface AccessLogOverviewFilters { + node_id?: string; + host?: string; + hosts?: string[]; + hours?: number; + bucket_minutes?: number; +} + +export interface AccessLogOverviewMetricPoint { + bucket_started_at: string; + value: number; +} + +export interface AccessLogOverview { + generated_at: string; + hours: number; + bucket_minutes?: number; + summary: { + total_requests: number; + total_visits: number; + bandwidth_served: number; + }; + trends: { + requests: AccessLogOverviewMetricPoint[]; + visits: AccessLogOverviewMetricPoint[]; + bandwidth: AccessLogOverviewMetricPoint[]; + }; + top_paths: DistributionItem[]; + top_hosts: DistributionItem[]; + top_ips: DistributionItem[]; + device_types: DistributionItem[]; + top_browsers: DistributionItem[]; + top_operating_systems: DistributionItem[]; + top_user_agents: DistributionItem[]; + status_codes: DistributionItem[]; +} + +export interface AccessLogItem { + id: string; + node_id: string; + node_name: string; + logged_at: string; + remote_addr: string; + region: string; + host: string; + path: string; + user_agent: string; + cache_status: string; + status_code: number; + bytes_sent: number; + request_length: number; + request_time_ms: number; + created_at: string; +} + +export interface AccessLogList { + items: AccessLogItem[]; + page: number; + page_size: number; + has_more: boolean; + total_record: number; + total_ip: number; +} + +export interface FoldedAccessLogFilters extends AccessLogFilters { + fold_minutes: 3 | 5; +} + +export interface FoldedAccessLogItem { + bucket_started_at: string; + request_count: number; + unique_ip_count: number; + unique_host_count: number; + success_count: number; + client_error_count: number; + server_error_count: number; +} + +export interface FoldedAccessLogList { + items: FoldedAccessLogItem[]; + page: number; + page_size: number; + has_more: boolean; + total_bucket: number; + total_record: number; + total_ip: number; + fold_minutes: number; +} + +export interface FoldedAccessLogIPFilters extends FoldedAccessLogFilters { + bucket_started_at: string; +} + +export interface FoldedAccessLogIPItem { + remote_addr: string; + request_count: number; + success_count: number; + client_error_count: number; + server_error_count: number; + last_seen_at: string; +} + +export interface FoldedAccessLogIPList { + items: FoldedAccessLogIPItem[]; + page: number; + page_size: number; + has_more: boolean; + total_ip: number; + bucket_started_at: string; + fold_minutes: number; + sort_by: string; + sort_order: 'asc' | 'desc'; +} + +export interface AccessLogIPSummaryFilters { + node_id?: string; + remote_addr?: string; + host?: string; + hours?: number; + since?: string; + until?: string; + p?: number; + page_size?: number; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + +export interface AccessLogIPSummaryItem { + remote_addr: string; + region?: string; + total_requests: number; + success_2xx_count: number; + success_ratio: number; + bytes_received: number; + bytes_sent: number; + /** @deprecated always 0 */ + recent_requests?: number; + last_seen_at: string; +} + +export interface AccessLogIPSummaryList { + items: AccessLogIPSummaryItem[]; + page: number; + page_size: number; + has_more: boolean; + total_ip: number; + hours: number; + since: string; + until?: string; + sort_by: string; + sort_order: 'asc' | 'desc'; +} + +export interface AccessLogIPTrendFilters { + node_id?: string; + remote_addr: string; + host?: string; + hours?: number; + bucket_minutes?: number; +} + +export interface AccessLogIPTrendPoint { + bucket_started_at: string; + request_count: number; +} + +export interface AccessLogIPTrend { + remote_addr: string; + hours: number; + bucket_minutes: number; + points: AccessLogIPTrendPoint[]; +} + +export interface AccessLogIPAnalysisFilters { + node_id?: string; + remote_addr: string; + host?: string; + hours?: number; +} + +export interface AccessLogIPAnalysis { + remote_addr: string; + hours: number; + generated_at: string; + summary: { + total_requests: number; + error_count: number; + bandwidth_served: number; + bytes_received: number; + unique_hosts: number; + unique_paths: number; + }; + top_paths: DistributionItem[]; + top_hosts: DistributionItem[]; + status_codes: DistributionItem[]; + top_user_agents: DistributionItem[]; + device_types: DistributionItem[]; + top_browsers: DistributionItem[]; +} + +export interface AccessLogCleanupPayload { + retention_days: number; +} + +export interface AccessLogCleanupResult { + retention_days: number; + deleted_count: number; + cutoff: string; +} + +// ==================== Options (Performance) ==================== + +export interface OptionItem { + key: string; + value: string; +} + +export interface OptionBatchPayload { + options: OptionItem[]; +} + +export interface GeoIPLookupResult { + provider: string; + ip: string; + iso_code: string; + name: string; + latitude?: number | null; + longitude?: number | null; +} + +export interface OpenFlarePublicStatus { + version: string; + start_time: number; + server_address: string; + system_name: string; +} + +export interface IPMatchConfig { + ips: string[]; + cidrs: string[]; + ip_group_ids: number[]; +} + +export interface GeoMatchConfig { + countries: string[]; + regions: string[]; +} + +export interface PoWNodeConfig { + algorithm: 'fast' | 'slow'; + difficulty: number; + session_ttl: number; + challenge_ttl: number; +} + +export interface BlockNodeConfig { + status_code: number; + response_body: string; +} + +export interface UACheckConfig { + require_ua: boolean; + browsers: string[]; + operating_systems: string[]; + match_mode: 'and' | 'or'; + block_common_bots: boolean; + block_abnormal_ua: boolean; + block_custom_ua: boolean; + custom_ua_patterns: string[]; +} + +export interface SecurityCheckConfig { + sql_injection: boolean; + path_traversal: boolean; + command_injection: boolean; + xss: boolean; + ssrf: boolean; + file_inclusion: boolean; + malicious_upload: boolean; + xxe: boolean; + crlf_injection: boolean; +} + +export type WAFRuleNode = + | { + id: string; + type: 'start'; + label?: string; + position: XYPosition; + config: Record; + } + | { + id: string; + type: 'ip_match'; + label?: string; + position: XYPosition; + config: IPMatchConfig; + } + | { + id: string; + type: 'geo_match'; + label?: string; + position: XYPosition; + config: GeoMatchConfig; + } + | { + id: string; + type: 'ua_check'; + label?: string; + position: XYPosition; + config: UACheckConfig; + } + | { + id: string; + type: 'security_check'; + label?: string; + position: XYPosition; + config: SecurityCheckConfig; + } + | { + id: string; + type: 'pow'; + label?: string; + position: XYPosition; + config: PoWNodeConfig; + } + | { + id: string; + type: 'allow'; + label?: string; + position: XYPosition; + config: Record; + } + | { + id: string; + type: 'block'; + label?: string; + position: XYPosition; + config: BlockNodeConfig; + }; + +export interface WAFRuleEdge { + id: string; + source: string; + source_handle: string; + target: string; +} + +export interface WAFRuleGraph { + schema_version: number; + nodes: WAFRuleNode[]; + edges: WAFRuleEdge[]; +} + +export interface WAFRule { + id: number; + name: string; + enabled: boolean; + is_global: boolean; + graph: WAFRuleGraph; + revision: number; + applied_site_ids: number[]; + applied_site_count: number; + created_at: string; + updated_at: string; +} + +/** @deprecated Legacy fixed-chain rule shape retained for untouched binding consumers. */ +export interface WAFRuleGroup extends WAFRule { + block_status_code: number; + block_response_body: string; + ip_whitelist: string[]; + ip_blacklist: string[]; + ip_whitelist_group_ids: number[]; + ip_blacklist_group_ids: number[]; + country_whitelist: string[]; + country_blacklist: string[]; + region_whitelist: string[]; + region_blacklist: string[]; + pow_enabled: boolean; + pow_config: ProxyRoutePoWConfig; +} + +export interface WAFCreateRulePayload { + name: string; +} + +export interface WAFSaveRuleGraphPayload { + revision: number; + graph: WAFRuleGraph; +} + +export interface WAFUpdateRuleMetaPayload { + name: string; + enabled: boolean; +} + +export interface WAFRuleGroupPayload { + name: string; + enabled: boolean; + block_status_code: number; + block_response_body: string; + ip_whitelist: string[]; + ip_blacklist: string[]; + ip_whitelist_group_ids: number[]; + ip_blacklist_group_ids: number[]; + country_whitelist: string[]; + country_blacklist: string[]; + region_whitelist: string[]; + region_blacklist: string[]; + pow_enabled: boolean; + pow_config: ProxyRoutePoWConfig; +} + +export interface WAFSiteRuleGroups { + route_id: number; + global_rule_group: WAFRule | null; + rule_groups: WAFRule[]; + applied_rule_groups: WAFRule[]; + applied_ids: number[]; +} + +export type WAFIPGroupType = 'manual' | 'automatic' | 'subscription'; +export type WAFIPGroupSubscriptionFormat = 'text' | 'json'; + +export interface WAFIPGroupExtIP { + ip: string; + captured_at: string; +} + +export interface WAFIPGroup { + id: number; + name: string; + type: WAFIPGroupType; + enabled: boolean; + ip_list: string[]; + auto_config: Record; + ext_ips?: WAFIPGroupExtIP[]; + subscription_url: string; + subscription_format: WAFIPGroupSubscriptionFormat; + subscription_mapping_rule: string; + sync_interval_minutes: number; + last_synced_at?: string; + next_sync_at?: string; + last_sync_status: string; + last_sync_message: string; + referenced_by_rule_count: number; + created_at: string; + updated_at: string; +} + +export interface WAFIPGroupPayload { + name: string; + type: WAFIPGroupType; + enabled: boolean; + ip_list: string[]; + auto_config: Record; + subscription_url: string; + subscription_format: WAFIPGroupSubscriptionFormat; + subscription_mapping_rule: string; + sync_interval_minutes: number; +} + +export interface WAFIPGroupSyncResult { + group: WAFIPGroup; + ip_count: number; + synced_at: string; + next_sync_at: string; + status: string; + message: string; +} + +export interface WAFIPGroupAutoTestPayload { + auto_config: Record; +} + +export interface WAFIPGroupAutoTestResult { + matched_ips: string[]; + matched_count: number; + lookback: string; + rule_count: number; + tested_at: string; +} + +export interface DashboardSummary { + total_nodes: number; + online_nodes: number; + offline_nodes: number; + pending_nodes: number; + unhealthy_nodes: number; +} + +export interface DashboardTraffic { + request_count: number; + unique_visitors: number; + error_count: number; + estimated_qps: number; + reported_nodes: number; +} + +export interface DashboardCapacity { + average_cpu_usage_percent: number; + average_memory_usage_percent: number; + high_cpu_nodes: number; + high_memory_nodes: number; + high_storage_nodes: number; +} + +export interface DistributionItem { + key: string; + value: number; +} + +export type CompactDistributionItem = [string, number]; + +export interface TrafficTrendPoint { + bucket_started_at: string; + request_count: number; + error_count: number; + unique_visitor_count: number; + status_2xx_count: number; + status_4xx_count: number; + status_5xx_count: number; +} + +export interface CapacityTrendPoint { + bucket_started_at: string; + average_cpu_usage_percent: number; + average_memory_usage_percent: number; + reported_nodes: number; +} + +export interface NetworkTrendPoint { + bucket_started_at: string; + /** L1 接收数据 sum(request_length) */ + bytes_received: number; + /** L1 已提供数据 sum(bytes_sent) */ + bytes_provided: number; + reported_nodes: number; +} + +export interface DiskIOTrendPoint { + bucket_started_at: string; + disk_read_bytes: number; + disk_write_bytes: number; + reported_nodes: number; +} + +export interface TrafficDistributions { + status_codes: DistributionItem[]; + top_domains: DistributionItem[]; + source_countries: DistributionItem[]; +} + +export interface DashboardTrends { + traffic_24h: TrafficTrendPoint[]; + capacity_24h: CapacityTrendPoint[]; + network_24h: NetworkTrendPoint[]; + disk_io_24h: DiskIOTrendPoint[]; +} + +export interface DashboardNodeHealth { + id: number; + node_id: string; + name: string; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; + status: NodeStatus; + openresty_status: OpenrestyStatus; + current_version: string; + last_seen_at: string; + active_event_count: number; + cpu_usage_percent: number; + memory_usage_percent: number; + storage_usage_percent: number; + request_count: number; + error_count: number; + unique_visitor_count: number; +} + +export interface DashboardOverview { + generated_at: string; + summary: DashboardSummary; + traffic: DashboardTraffic; + capacity: DashboardCapacity; + distributions: TrafficDistributions; + trends: DashboardTrends; + nodes: DashboardNodeHealth[]; +} + +export type CompactTrafficTrendPoint = [ + string, + number, + number, + number, + number, + number, + number, +]; +export type CompactCapacityTrendPoint = [string, number, number, number]; +/** [bucket, bytes_received, bytes_provided, reported_nodes] */ +export type CompactNetworkTrendPoint = [string, number, number, number]; +export type CompactDiskIOTrendPoint = [string, number, number, number]; +export type CompactDashboardNodeHealth = [ + number, + string, + string, + string, + number | null, + number | null, + DashboardNodeHealth['status'], + DashboardNodeHealth['openresty_status'], + string, + string, + number, + number, + number, + number, + number, + number, + number, +]; + +export interface DashboardOverviewCompact { + generated_at: string; + summary: DashboardSummary; + traffic: DashboardTraffic; + capacity: DashboardCapacity; + distributions: { + status_codes: CompactDistributionItem[]; + top_domains: CompactDistributionItem[]; + source_countries: CompactDistributionItem[]; + }; + trends: { + traffic_24h: CompactTrafficTrendPoint[]; + capacity_24h: CompactCapacityTrendPoint[]; + network_24h: CompactNetworkTrendPoint[]; + disk_io_24h: CompactDiskIOTrendPoint[]; + }; + nodes: CompactDashboardNodeHealth[]; +} + +// ==================== Websites / TLS / DNS ==================== + +export interface ZoneItem { + id: number; + domain: string; + /** Present on list API; may be omitted on nested zone objects. */ + domain_count?: number; + created_at: string; + updated_at: string; +} + +export interface ZoneMutationPayload { + domain: string; +} + +export interface ZoneDomainItem { + id: number; + zone_id: number; + proxy_route_id: number | null; + domain: string; + cert_id: number | null; + created_at: string; + updated_at: string; +} + +export interface ZoneDomainMutationPayload { + domain: string; + cert_id: number | null; +} + +export interface ZoneOverview { + zone: ZoneItem; + domains: ZoneDomainItem[]; +} + +export type ZoneStatsRange = '24h' | '7d' | '30d'; + +export interface ZoneStatsPoint { + bucket_started_at: string; + request_count: number; + unique_visitors: number; + bytes_sent: number; +} + +export interface ZoneStats { + range: ZoneStatsRange; + range_hours: number; + window_started_at: string; + window_ended_at: string; + bucket_minutes: number; + unique_visitors: number; + request_count: number; + bytes_sent: number; + domain_count: number; + available: boolean; + series: ZoneStatsPoint[]; +} + +export type CloudflareConnectionSource = 'dns_account' | 'standalone'; +export type CloudflareSyncStatus = 'pending' | 'syncing' | 'ok' | 'error'; + +export interface CloudflareConnection { + configured: boolean; + ready: boolean; + source: CloudflareConnectionSource | ''; + dns_account_id: number | null; + status: string; + verified_at: string | null; +} + +export interface CloudflareConnectionPayload { + source: CloudflareConnectionSource; + dns_account_id: number; + api_token: string; +} + +export interface CloudflareNodeOption { + id: number; + name: string; + ip: string; +} + +export interface CloudflareGroup { + id: number; + name: string; + primary_node: CloudflareNodeOption; + backup_node: CloudflareNodeOption | null; + active_node: CloudflareNodeOption; + default_proxied: boolean; + enabled: boolean; + member_count: number; + created_at: string; + updated_at: string; +} + +export interface CloudflareGroupPayload { + name: string; + primary_node_id: number; + backup_node_id: number | null; + default_proxied: boolean; + enabled: boolean; +} + +export interface CloudflareMember { + id: number; + group_id: number; + zone_domain_id: number; + domain: string; + zone_id: number; + proxied: boolean; + desired_ip: string; + sync_status: CloudflareSyncStatus; + last_error: string; + synced_at: string | null; +} + +export interface CloudflareMemberCreatePayload { + zone_domain_id: number; + proxied?: boolean; +} + +export interface CloudflareGroupDetail { + group: CloudflareGroup; + members: CloudflareMember[]; +} + +export interface CloudflareAvailableDomain { + id: number; + zone_id: number; + domain: string; + /** Zone apex / root domain used for hierarchical grouping. */ + zone_domain: string; +} + +export interface CloudflareOverview { + connection: CloudflareConnection; + group_count: number; + member_count: number; + ok_count: number; + pending_count: number; + error_count: number; +} + +export interface CloudflareSyncReceipt { + task_id: string; +} + +export interface TlsCertificateItem { + id: number; + name: string; + cert_pem?: string; + key_pem?: string; + provider: string; + acme_account_id: number; + dns_account_id: number; + key_algorithm: string; + auto_renew: boolean; + primary_domain: string; + other_domains: string; + disable_cname: boolean; + skip_dns: boolean; + dns1: string; + dns2: string; + apply_status: string; + apply_message: string; + not_before: string; + not_after: string; + remark: string; + created_at: string; + updated_at: string; +} + +export interface TlsCertificateDetailItem extends TlsCertificateItem { + cert_pem?: never; + key_pem?: never; +} + +export interface TlsCertificateContentItem extends TlsCertificateItem { + cert_pem: string; + key_pem: string; +} + +export interface TlsCertificateMutationPayload { + name: string; + cert_pem: string; + key_pem: string; + remark: string; +} + +export interface TlsCertificateApplyPayload { + name: string; + remark: string; + acme_account_id: number; + dns_account_id: number; + key_algorithm: string; + auto_renew: boolean; + primary_domain: string; + other_domains: string; + disable_cname: boolean; + skip_dns: boolean; + dns1: string; + dns2: string; +} + +export interface TlsCertificateFileImportPayload { + name: string; + remark: string; + certFile: File; + keyFile: File; +} + +export interface AcmeAccountItem { + id: number; + email: string; + url: string; + created_at: string; + updated_at: string; +} + +export interface DnsAccountItem { + id: number; + name: string; + type: string; + created_at: string; + updated_at: string; +} + +export interface DnsAccountMutationPayload { + name: string; + type: string; + authorization: string; +} diff --git a/frontend/lib/services/openflare/uptimekuma.service.ts b/frontend/lib/services/openflare/uptimekuma.service.ts new file mode 100644 index 00000000..4f315978 --- /dev/null +++ b/frontend/lib/services/openflare/uptimekuma.service.ts @@ -0,0 +1,9 @@ +import { OpenFlareBaseService } from './base.service'; + +export class UptimeKumaService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/uptimekuma'; + + static sync(): Promise { + return this.post('/sync'); + } +} diff --git a/frontend/lib/services/openflare/waf.service.ts b/frontend/lib/services/openflare/waf.service.ts new file mode 100644 index 00000000..54454e1f --- /dev/null +++ b/frontend/lib/services/openflare/waf.service.ts @@ -0,0 +1,93 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + WAFIPGroup, + WAFIPGroupAutoTestPayload, + WAFIPGroupAutoTestResult, + WAFIPGroupPayload, + WAFIPGroupSyncResult, + WAFCreateRulePayload, + WAFRule, + WAFSaveRuleGraphPayload, + WAFSiteRuleGroups, + WAFUpdateRuleMetaPayload, +} from './types'; + +export class WafService extends OpenFlareBaseService { + protected static override readonly basePath: string = '/api/v1/d/waf'; + + static async listRuleGroups(): Promise { + return this.get('/rule-groups'); + } + + static async getRule(id: number): Promise { + return this.get(`/rule-groups/${id}`); + } + + static async createRule(payload: WAFCreateRulePayload): Promise { + return this.post('/rule-groups', payload); + } + + static async saveRuleGraph( + id: number, + payload: WAFSaveRuleGraphPayload, + ): Promise { + return this.post(`/rule-groups/${id}/graph`, payload); + } + + static async updateRuleMeta( + id: number, + payload: WAFUpdateRuleMetaPayload, + ): Promise { + return this.post(`/rule-groups/${id}/meta`, payload); + } + + static async deleteRuleGroup(id: number): Promise { + return this.post(`/rule-groups/${id}/delete`); + } + + static async listSiteRuleGroups(routeId: number): Promise { + return this.get(`/sites/${routeId}/rule-groups`); + } + + static async updateSiteRuleGroups( + routeId: number, + ids: number[], + ): Promise { + return this.post(`/sites/${routeId}/rule-groups`, { + ids, + }); + } + + static async listIPGroups(): Promise { + return this.get('/ip-groups'); + } + + static async getIPGroup(id: number): Promise { + return this.get(`/ip-groups/${id}`); + } + + static async createIPGroup(payload: WAFIPGroupPayload): Promise { + return this.post('/ip-groups', payload); + } + + static async updateIPGroup( + id: number, + payload: WAFIPGroupPayload, + ): Promise { + return this.post(`/ip-groups/${id}/update`, payload); + } + + static async deleteIPGroup(id: number): Promise { + return this.post(`/ip-groups/${id}/delete`); + } + + static async testIPGroup( + payload: WAFIPGroupAutoTestPayload, + ): Promise { + return this.post('/ip-groups/test', payload); + } + + static async syncIPGroup(id: number): Promise { + return this.post(`/ip-groups/${id}/sync`); + } +} diff --git a/frontend/lib/services/openflare/zone.service.ts b/frontend/lib/services/openflare/zone.service.ts new file mode 100644 index 00000000..6f6ea37d --- /dev/null +++ b/frontend/lib/services/openflare/zone.service.ts @@ -0,0 +1,62 @@ +import { OpenFlareBaseService } from './base.service'; +import type { + ZoneDomainItem, + ZoneDomainMutationPayload, + ZoneItem, + ZoneMutationPayload, + ZoneOverview, + ZoneStats, + ZoneStatsRange, +} from './types'; + +export const zoneQueryKey = ['openflare', 'zones'] as const; + +export class ZoneService extends OpenFlareBaseService { + protected static override readonly basePath = '/api/v1/d/zones'; + + static list(): Promise { + return this.get('/'); + } + static getOverview(id: number): Promise { + return this.get(`/${id}/overview`); + } + static getStats( + id: number, + range: ZoneStatsRange = '24h', + ): Promise { + return this.get(`/${id}/stats`, { range }); + } + static create(payload: ZoneMutationPayload): Promise { + return this.post('/', payload); + } + static update(id: number, payload: ZoneMutationPayload): Promise { + return this.post(`/${id}/update`, payload); + } + static deleteById(id: number): Promise { + return this.post(`/${id}/delete`); + } +} + +export class ZoneDomainService extends OpenFlareBaseService { + protected static override readonly basePath = '/api/v1/d/zones'; + + static create( + zoneId: number, + payload: ZoneDomainMutationPayload, + ): Promise { + return this.post(`/${zoneId}/domains`, payload); + } + static update( + zoneId: number, + id: number, + payload: ZoneDomainMutationPayload, + ): Promise { + return this.post( + `/${zoneId}/domains/${id}/update`, + payload, + ); + } + static deleteById(zoneId: number, id: number): Promise { + return this.post(`/${zoneId}/domains/${id}/delete`); + } +} diff --git a/frontend/lib/services/upload/upload.service.ts b/frontend/lib/services/upload/upload.service.ts index 427804a0..09c8839a 100644 --- a/frontend/lib/services/upload/upload.service.ts +++ b/frontend/lib/services/upload/upload.service.ts @@ -1,5 +1,6 @@ import type { InternalAxiosRequestConfig } from 'axios'; +import { apiConfig } from '../core/config'; import { BaseService } from '../core/base.service'; import type { ListUploadsResponse, Upload, UploadImageResponse } from './types'; @@ -23,6 +24,7 @@ export class UploadService extends BaseService { } return this.post('', formData, { + timeout: apiConfig.uploadTimeout, headers: { 'Content-Type': 'multipart/form-data' }, } as InternalAxiosRequestConfig); } diff --git a/frontend/lib/theme/context.tsx b/frontend/lib/theme/context.tsx index 7671acdb..53824302 100644 --- a/frontend/lib/theme/context.tsx +++ b/frontend/lib/theme/context.tsx @@ -2,10 +2,10 @@ import React, { createContext, + useCallback, useContext, useEffect, useState, - useCallback, } from 'react'; import { useTheme as useNextTheme } from 'next-themes'; import type { Theme } from './types'; diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index c38cfb5f..e283c4ea 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -1,4 +1,4 @@ -import { clsx, type ClassValue } from 'clsx'; +import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { @@ -105,7 +105,7 @@ export function generateTransactionCacheKey(params: { */ export function safeRedirectTarget( url: string | null | undefined, - fallback = '/home', + fallback = '/', ): string { if (!url) return fallback; diff --git a/frontend/lib/utils/metrics.ts b/frontend/lib/utils/metrics.ts new file mode 100644 index 00000000..ffa293b2 --- /dev/null +++ b/frontend/lib/utils/metrics.ts @@ -0,0 +1,83 @@ +const compactNumberFormatter = new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 1, +}); + +type FormatBytesOptions = { + emptyText?: string; + zeroText?: string; +}; + +export function formatCompactNumber(value?: number | null) { + if (value === undefined || value === null || Number.isNaN(value)) { + return '—'; + } + return compactNumberFormatter.format(value); +} + +export function formatPercent(value?: number | null) { + if (value === undefined || value === null || Number.isNaN(value)) { + return '—'; + } + return `${formatCompactNumber(value)}%`; +} + +export function formatBytes( + value?: number | null, + options: FormatBytesOptions = {}, +) { + const { emptyText = '—', zeroText = '0 B' } = options; + + if (value === undefined || value === null || Number.isNaN(value)) { + return emptyText; + } + if (value <= 0) { + return zeroText; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let current = value; + let index = 0; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + + const digits = current >= 100 || index === 0 ? 0 : 1; + return `${new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: digits, + minimumFractionDigits: 0, + }).format(current)} ${units[index]}`; +} + +export function formatBytesPerSecond( + value?: number | null, + windowSeconds = 1, + options?: FormatBytesOptions, +) { + if (value === undefined || value === null || Number.isNaN(value)) { + return options?.emptyText ?? '—'; + } + if (windowSeconds <= 0) { + return options?.emptyText ?? '—'; + } + return `${formatBytes(value / windowSeconds, options)}/s`; +} + +export function calculateNiceAxisMax(values: number[]) { + const rawMax = Math.max( + 0, + ...values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0)), + ); + + if (rawMax <= 0) { + return 1; + } + + const paddedMax = rawMax * 1.1; + const magnitude = 10 ** Math.floor(Math.log10(paddedMax)); + const normalized = paddedMax / magnitude; + const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; + const niceStep = steps.find((step) => normalized <= step) ?? 10; + + return niceStep * magnitude; +} diff --git a/frontend/lib/utils/search-data.ts b/frontend/lib/utils/search-data.ts index 78cfd739..b6cd5491 100644 --- a/frontend/lib/utils/search-data.ts +++ b/frontend/lib/utils/search-data.ts @@ -29,14 +29,19 @@ const match = ((): MatchFunction | null => { return null; })(); -export interface SearchItem { +export interface SearchItemSource { id: string; - title: string; - description: string; + titleKey: string; + descriptionKey: string; url: string; category: 'page' | 'feature' | 'setting' | 'admin'; keywords: string[]; icon?: string; +} + +export interface SearchItem extends SearchItemSource { + title: string; + description: string; matchRange?: [number, number]; } @@ -44,65 +49,220 @@ export interface SearchItem { * 全局搜索数据源 * 包含所有可搜索的页面和功能 */ -export const searchData: SearchItem[] = [ - // ==================== 首页 ==================== +export const searchData: SearchItemSource[] = [ + // ==================== 总览 ==================== { id: 'home', - title: '首页', - description: '返回首页控制台', - url: '/home', + titleKey: 'nav.dashboard', + descriptionKey: 'search.items.homeDesc', + url: '/', category: 'page', - keywords: ['home', '主页', '首页', 'dashboard'], + keywords: ['home', '主页', '首页', 'dashboard', '总览'], }, // ==================== 文档库 ==================== - { - id: 'docs-api', - title: '开发接口文档', - description: '查看 RESTful API 接口规格定义', - url: '/docs/api', - category: 'page', - keywords: ['api', 'docs', '文档', '接口', 'specification'], - }, { id: 'docs-how-to-use', - title: '使用帮助文档', - description: '查看新手教程和集成示例', - url: '/docs/how-to-use', + titleKey: 'nav.usageDocs', + descriptionKey: 'search.items.usageDocsDesc', + url: 'https://openflare.fyrn.link/', category: 'page', keywords: ['docs', '文档', '使用', 'how to', 'tutorial', '教程', 'help'], }, + // ==================== 业务控制台 ==================== + { + id: 'console-nodes', + titleKey: 'nav.nodes', + descriptionKey: 'search.items.nodesDesc', + url: '/nodes', + category: 'page', + keywords: [ + 'node', + '节点', + '边缘节点', + '中继', + '内网穿透', + 'tunnel', + '服务器', + ], + }, + { + id: 'console-proxy-routes', + titleKey: 'nav.proxyRoutes', + descriptionKey: 'search.items.proxyRoutesDesc', + url: '/proxy-routes', + category: 'page', + keywords: ['route', '规则', '路由', '代理', '反向代理', 'proxy'], + }, + { + id: 'console-websites', + titleKey: 'nav.websites', + descriptionKey: 'search.items.websitesDesc', + url: '/websites', + category: 'page', + keywords: ['website', 'domain', '网站', '域名', '站点'], + }, + { + id: 'console-certificates', + titleKey: 'nav.certificates', + descriptionKey: 'search.items.certificatesDesc', + url: '/certificates', + category: 'page', + keywords: ['certificate', 'ssl', 'tls', '证书', 'https', '加密'], + }, + { + id: 'console-dns-accounts', + titleKey: 'nav.dnsAccounts', + descriptionKey: 'search.items.dnsAccountsDesc', + url: '/dns-accounts', + category: 'page', + keywords: [ + 'dns', + 'dns account', + '账号', + '域名解析', + 'cloudflare', + 'aliyun', + 'tencent', + ], + }, + { + id: 'console-origins', + titleKey: 'nav.origins', + descriptionKey: 'search.items.originsDesc', + url: '/origins', + category: 'page', + keywords: ['origin', '源站', '后端', 'backend', '服务器', '负载均衡'], + }, + { + id: 'console-responses', + titleKey: 'nav.responses', + descriptionKey: 'search.items.responsesDesc', + url: '/responses', + category: 'page', + keywords: [ + '响应页面', + '错误页', + '联系页', + '源站', + '502', + '503', + '5xx', + 'Service Worker', + '离线兜底', + 'error page', + ], + }, + { + id: 'console-waf', + titleKey: 'nav.waf', + descriptionKey: 'search.items.wafDesc', + url: '/waf', + category: 'page', + keywords: ['waf', '防火墙', '安全', 'security', '拦截', '规则'], + }, + { + id: 'console-ip-groups', + titleKey: 'nav.ipGroups', + descriptionKey: 'search.items.ipGroupsDesc', + url: '/ip-groups', + category: 'page', + keywords: ['ip', 'ip group', 'ip组', '黑名单', '白名单', '访问控制'], + }, + { + id: 'console-rate-limits', + titleKey: 'nav.rateLimits', + descriptionKey: 'search.items.rateLimitsDesc', + url: '/rate-limits', + category: 'page', + keywords: [ + '限流', + 'rate limit', + 'limit_conn', + 'limit_rate', + '并发', + '带宽', + ], + }, + { + id: 'console-pages', + titleKey: 'nav.pages', + descriptionKey: 'search.items.pagesDesc', + url: '/pages', + category: 'page', + keywords: ['pages', '静态托管', 'cdn', '网站', '部署', 'static'], + }, + { + id: 'console-config-versions', + titleKey: 'nav.configVersions', + descriptionKey: 'search.items.configVersionsDesc', + url: '/config-versions', + category: 'page', + keywords: ['version', 'config', '版本', '发布', '回滚', '对比', '部署'], + }, + { + id: 'console-access-logs', + titleKey: 'nav.accessLogs', + descriptionKey: 'search.items.accessLogsDesc', + url: '/access-logs', + category: 'page', + keywords: ['log', 'logs', '访问日志', '分析', '流量', '请求'], + }, + { + id: 'console-apply-logs', + titleKey: 'nav.applyLogs', + descriptionKey: 'search.items.applyLogsDesc', + url: '/apply-logs', + category: 'page', + keywords: [ + 'apply', + 'log', + 'logs', + '应用记录', + '配置下发', + '同步', + '部署历史', + ], + }, + { + id: 'console-performance', + titleKey: 'nav.performance', + descriptionKey: 'search.items.performanceDesc', + url: '/performance', + category: 'page', + keywords: ['performance', '性能', '调优', '优化', '参数', '连接', '超时'], + }, + // ==================== 个人设置 ==================== { id: 'settings', - title: '全局设置', - description: '配置应用个人偏好选项', + titleKey: 'search.items.globalSettings', + descriptionKey: 'search.items.settingsDesc', url: '/settings', category: 'setting', keywords: ['settings', '设置', '偏好', 'preferences'], }, { id: 'settings-profile', - title: '我的资料', - description: '编辑昵称、头像和个人属性', + titleKey: 'user.profile', + descriptionKey: 'search.items.profileDesc', url: '/settings/profile', category: 'setting', keywords: ['profile', '资料', '个人', '我的', '信息', 'avatar'], }, { id: 'settings-appearance', - title: '外观设置', - description: '配置系统显示主题(亮色/暗色)', + titleKey: 'search.items.appearance', + descriptionKey: 'search.items.appearanceDesc', url: '/settings/appearance', category: 'setting', keywords: ['appearance', '外观', '主题', 'theme', 'dark', 'light'], }, - // ==================== 管理员 ==================== { id: 'admin-settings', - title: '系统设置', - description: '管理系统登录注册与认证源配置 (管理员专属)', + titleKey: 'nav.adminSettings', + descriptionKey: 'search.items.adminSettingsDesc', url: '/admin/settings', category: 'admin', keywords: [ @@ -117,24 +277,24 @@ export const searchData: SearchItem[] = [ }, { id: 'admin-system', - title: '系统配置', - description: '动态修改平台核心运行时配置 (管理员专属)', + titleKey: 'nav.system', + descriptionKey: 'search.items.systemDesc', url: '/admin/system', category: 'admin', keywords: ['admin', '管理员', '系统', '配置', 'system', 'configurations'], }, { id: 'admin-users', - title: '用户管理', - description: '管理平台注册用户的活跃状态 (管理员专属)', + titleKey: 'nav.users', + descriptionKey: 'search.items.usersDesc', url: '/admin/users', category: 'admin', keywords: ['admin', '管理员', '用户', '管理', 'users', 'status'], }, { id: 'admin-tasks', - title: '异步任务管理', - description: '下发与排查后台异步定时任务 (管理员专属)', + titleKey: 'nav.tasks', + descriptionKey: 'search.items.tasksDesc', url: '/admin/tasks', category: 'admin', keywords: [ @@ -147,17 +307,60 @@ export const searchData: SearchItem[] = [ 'worker', ], }, + { + id: 'admin-files', + titleKey: 'nav.storage', + descriptionKey: 'search.items.storageDesc', + url: '/admin/files', + category: 'admin', + keywords: ['admin', '管理员', '存储', '文件', 'files', 'upload', 's3'], + }, + { + id: 'admin-database', + titleKey: 'nav.database', + descriptionKey: 'search.items.databaseDesc', + url: '/admin/database', + category: 'admin', + keywords: ['admin', '管理员', '数据库', 'database', 'sql', 'query', 'gorm'], + }, + { + id: 'admin-push', + titleKey: 'nav.push', + descriptionKey: 'search.items.pushDesc', + url: '/admin/push', + category: 'admin', + keywords: [ + 'admin', + '管理员', + '推送', + '通知', + 'push', + 'mail', + 'telegram', + 'lark', + ], + }, + { + id: 'admin-logs', + titleKey: 'nav.logs', + descriptionKey: 'search.items.logsDesc', + url: '/admin/logs', + category: 'admin', + keywords: ['admin', '管理员', '日志', 'logs', 'system log', 'terminal'], + }, ]; /** * 搜索功能 * @param query 搜索关键词 * @param isAdmin 是否为管理员 + * @param t 文案解析函数(layout 命名空间) * @returns 匹配的搜索结果 */ export function searchItems( query: string, isAdmin: boolean = false, + t: (key: string) => string, ): SearchItem[] { const trimmedQuery = query.trim(); @@ -166,11 +369,17 @@ export function searchItems( ? searchData : searchData.filter((item) => item.category !== 'admin'); + const resolved = filteredData.map((item) => ({ + ...item, + title: t(item.titleKey), + description: t(item.descriptionKey), + })); + if (!trimmedQuery) { - return filteredData; + return resolved; } - return filteredData + return resolved .map((item) => { // 优先匹配标题 const titleMatch = diff --git a/frontend/messages/en.json b/frontend/messages/fragments/admin.en.json similarity index 70% rename from frontend/messages/en.json rename to frontend/messages/fragments/admin.en.json index 98b3c01b..5944266e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/fragments/admin.en.json @@ -1,105 +1,4 @@ { - "common": { - "save": "Save", - "cancel": "Cancel", - "confirm": "Confirm", - "delete": "Delete", - "loading": "Loading...", - "retry": "Retry", - "optional": "(optional)", - "settings": "Settings", - "search": "Search", - "unknownError": "Something went wrong. Please try again.", - "loadFailed": "Load failed", - "previousPage": "Previous page", - "nextPage": "Next page" - }, - "layout": { - "nav": { - "home": "Home", - "myFiles": "My Files", - "users": "Users", - "tasks": "Tasks", - "storage": "Storage", - "database": "Database", - "push": "Notifications", - "messageGateway": "Messaging", - "logs": "System Logs", - "system": "System Config", - "adminSettings": "System Settings", - "demo": "Demo", - "apiDocs": "API Docs", - "usageDocs": "User Guide" - }, - "groups": { - "admin": "Admin", - "docs": "Docs" - }, - "user": { - "adminRole": "Administrator", - "userRole": "User", - "notLoggedIn": "Not signed in", - "syncing": "Syncing account", - "profile": "My Profile", - "settings": "Settings", - "help": "Help", - "logout": "Sign out" - }, - "logoutDialog": { - "title": "Sign out", - "description": "Are you sure you want to sign out?", - "loggingOut": "Signing out…", - "confirm": "Sign out", - "inProgress": "Signing out…", - "failed": "Sign-out failed", - "failedDescription": "An error occurred while signing out. Please try again." - }, - "header": { - "notifications": "Notifications", - "settings": "Settings", - "search": "Search", - "toggleFullWidth": "Toggle full width", - "toggleTheme": "Toggle theme", - "changePasswordBanner": "For your account security, please change your password now.", - "changePasswordAction": "Change password", - "bannerTitle": "Announcement", - "bannerLink": "Learn more", - "dismiss": "Dismiss" - }, - "search": { - "categoryPage": "Page", - "categoryFeature": "Feature", - "categorySetting": "Setting", - "categoryAdmin": "Admin", - "tipPrefix": "Tips:", - "tipSlash": "You can also press / to open this dialog", - "tipArrow": "to navigate items", - "tipNewTab": "Open in new tab", - "tipDidYouKnow": "Did you know: search is still being improved", - "searchPlaceholder": "Search pages and features...", - "noResults": "No results found. Try a different term?", - "openInNewTab": "Open in new tab", - "open": "Open", - "closeSearch": "Close search" - }, - "error": { - "loadFailed": "Load failed", - "retry": "Retry", - "unknownError": "An unknown error occurred. Please try again later.", - "viewDetails": "View error details", - "unknownErrorShort": "Unknown error" - }, - "loading": { - "loading": "Loading", - "loadingDesc": "Fetching data..." - }, - "empty": { - "noData": "No data", - "noContent": "No records found" - }, - "toggleSidebar": "Toggle sidebar", - "mainNavigation": "Main navigation" - }, "auth": { "login": { "title": "Sign in to your account", @@ -125,7 +24,14 @@ "registerNow": "Register", "codeSent": "A sign-in code was sent to your email", "codeTipMasked": "A sign-in code was sent to your secure email {email}.", - "codeTipGeneric": "Please enter your sign-in verification code." + "codeTipGeneric": "Please enter your sign-in verification code.", + "needBind": "Your third-party account is not linked to a local account, and registration is closed. Sign in with an existing local account to bind it.", + "bindSuccess": "Account bound", + "checkingSession": "Checking sign-in status", + "checkingSessionDesc": "Please wait while we confirm your session...", + "redirecting": "Redirecting to the console...", + "verifyingCredentials": "Verifying credentials", + "establishingSession": "Please wait while we establish a secure session..." }, "otp": { "title": "Verify your sign-in", @@ -248,8 +154,8 @@ "deleteFailed": "Failed to delete access token", "rotateSuccess": "Access token rotated; the old secret is invalid", "rotateFailed": "Failed to rotate access token", - "deleteConfirm": "Revoke token \u201c{name}\u201d? It will stop working immediately and cannot be recovered.", - "rotateConfirm": "Rotate the secret for token \u201c{name}\u201d? A new secret will be issued and the old one will stop working immediately.", + "deleteConfirm": "Revoke token “{name}”? It will stop working immediately and cannot be recovered.", + "rotateConfirm": "Rotate the secret for token “{name}”? A new secret will be issued and the old one will stop working immediately.", "deleteConfirmTitle": "Confirm token revocation", "rotateConfirmTitle": "Confirm token rotation", "confirmRevoke": "Confirm revoke", @@ -409,11 +315,12 @@ "type": "Type", "secretConfigured": "Secret configured", "secretNotConfigured": "Secret not configured", - "deleteAuthSourceConfirm": "Delete authentication source \u201c{name}\u201d? This cannot be undone.", + "deleteAuthSourceConfirm": "Delete authentication source “{name}”? This cannot be undone.", "deleteAuthSourceTitle": "Confirm delete authentication source", "confirmDelete": "Confirm delete", "deleting": "Deleting...", "noAuthSources": "No authentication sources configured. Click the button above to add one.", + "loginCaptchaEnabled": "Login CAPTCHA", "captchaConfig": "CAPTCHA configuration (Cap CAPTCHA)", "captchaConfigDesc": "Configure Proof-of-Work (PoW) based invisible CAPTCHA to protect login from brute-force and credential-stuffing attacks", "autoStartSolving": "Start solving automatically when page loads", @@ -465,7 +372,7 @@ "smtpPort": "SMTP port", "smtpUsername": "SMTP username", "smtpPassword": "SMTP password", - "smtpPasswordMasked": "\u2022\u2022\u2022\u2022\u2022\u2022 (configured, leave empty to keep)", + "smtpPasswordMasked": "•••••• (configured, leave empty to keep)", "smtpPasswordPlaceholder": "Enter password", "testSend": "Send test", "smtpTestTitle": "SMTP send test", @@ -485,7 +392,32 @@ "menuDisplayManagementDesc": "Configure the visibility of sidebar menu items for all logged-in users.", "notHideable": "Cannot be hidden", "securityTipTitle": "Security note:", - "securityTipDesc": "To prevent admins from being locked out after hiding \"System Settings\", the system restricts disabling this menu item. All other menu items can be freely toggled. When hidden, empty group headers are also automatically hidden." + "securityTipDesc": "To prevent admins from being locked out after hiding \"System Settings\", the system restricts disabling this menu item. All other menu items can be freely toggled. When hidden, empty group headers are also automatically hidden.", + "groupBusiness": "Business menu", + "groupAdmin": "Admin menu", + "groupDocs": "Docs menu", + "descDashboard": "View multi-dimensional charts for edge requests, visitors, and traffic", + "descNodes": "Register and manage reverse-proxy edge node status", + "descProxyRoutes": "Configure and publish proxy routes, load balancing, and origin bindings", + "descWebsites": "Configure root domains and view linked website details", + "descCertificates": "Manage and distribute website SSL/TLS certificates", + "descDnsAccounts": "Configure Cloudflare/Alidns and other DNS provider API credentials", + "descOrigins": "Centrally maintain backend origin server groups", + "descWaf": "Configure WAF custom rules and rate limits", + "descIpGroups": "Manage allow/deny IP sets used by WAF rules", + "descPages": "Host and publish static site projects with automatic domain binding", + "descConfigVersions": "View rule snapshots and perform hot updates or rollbacks", + "descAccessLogs": "Browse real-time edge request access details", + "descPerformance": "Configure cache, image compression, and other acceleration policies", + "descUsers": "View and manage system users and their status", + "descTasks": "View and schedule asynchronous and cron tasks", + "descStorage": "Manage file storage and clean unused files", + "descDatabase": "Monitor the physical database, browse tables, and run interactive SQL", + "descPush": "Configure and send system notifications", + "descLogs": "View async task execution logs and system runtime status", + "descSystem": "Manage core system key-value configuration", + "descAdminSettings": "Configure captcha, email, and menu visibility", + "descUsageDocs": "Deployment and operations guide for developers" }, "templates": { "templateCreated": "Notification template created", @@ -501,7 +433,7 @@ "custom": "Custom", "identifier": "Identifier", "subject": "Subject", - "deleteTemplateConfirm": "Delete template \u201c{name}\u201d? This cannot be undone.", + "deleteTemplateConfirm": "Delete template “{name}”? This cannot be undone.", "deleteTemplateTitle": "Confirm delete template", "confirmDelete": "Confirm delete", "deleting": "Deleting...", @@ -559,7 +491,21 @@ "selectBusinessType": "Select business type...", "allTypesAdded": "All types added", "currentAuthFreeList": "Current auth-free list", - "whitelistEmpty": "Whitelist is empty. All file types require login." + "whitelistEmpty": "Whitelist is empty. All file types require login.", + "typeAvatar": "Avatar (avatar)", + "typeAttachment": "Attachment (attachment)", + "typeDoc": "Document (doc)", + "typeGeneric": "Generic (generic)", + "logRetentionTitle": "Log retention", + "logRetentionDesc": "Configure retention days for each log database. Expired logs are cleaned automatically after switching the log store.", + "days": "days", + "save": "Save", + "logRetentionUpdated": "Log retention updated", + "logRetentionUpdateFailed": "Failed to update log retention", + "retentionInvalid": "{label} must be an integer greater than or equal to 1", + "retentionPostgresDesc": "Retention days for access logs and observability metrics", + "retentionSqliteDesc": "SQLite log retention days", + "retentionClickhouseDesc": "ClickHouse log retention days" }, "authSourceModal": { "authSourceSaved": "Authentication source saved", @@ -607,11 +553,18 @@ "countDistTitle": "File format distribution (count)", "countDistDesc": "Percentage of files by format type", "noCategoryData": "No category data", - "maxCountHint": "The most common file type is \u201c{name}\u201d with {count} files uploaded.", + "maxCountHint": "The most common file type is “{name}” with {count} files uploaded.", "sizeDistTitle": "File format distribution (size)", "sizeDistDesc": "Percentage of storage by format type", "noSizeData": "No size data", - "maxSizeHint": "The largest storage consumer is \u201c{name}\u201d, using {size} of storage." + "maxSizeHint": "The largest storage consumer is “{name}”, using {size} of storage.", + "catImages": "Images", + "catVideos": "Videos", + "catAudio": "Audio", + "catDocuments": "Documents", + "catArchives": "Archives", + "catOthers": "Others", + "none": "None" }, "list": { "searchPlaceholder": "Search file name...", @@ -652,15 +605,16 @@ "detailUploadedAt": "Uploaded at", "detailMetadata": "Extra metadata", "deleteTitle": "Confirm file deletion", - "deleteConfirm": "Delete file \u201c{name}\u201d? This action cannot be undone.", + "deleteConfirm": "Delete file “{name}”? This action cannot be undone.", "cancel": "Cancel", "confirmDelete": "Confirm delete", "deleteSuccess": "File deleted", - "deleteFailed": "Delete failed" + "deleteFailed": "Delete failed", + "extensionFile": "{ext} file" }, "storage": { "maintenanceMode": "Storage maintenance mode", - "maintenanceDesc": "{source} \u2192 {target}. During migration, files are read-only; upload, delete, and cleanup are disabled.", + "maintenanceDesc": "{source} → {target}. During migration, files are read-only; upload, delete, and cleanup are disabled.", "migrationProgress": "Check task execution log for migration progress: {taskId}", "notDispatched": "Not dispatched yet", "retryMigration": "Retry migration", @@ -672,7 +626,7 @@ "username": "Username", "password": "Password", "basePath": "Base path", - "switchWarning": "\u26a0\ufe0f You've switched storage types. Clicking \u201cSave configuration\u201d will switch the active storage engine and update existing file driver tags. Use \u201cStart migration\u201d only when physical file copying is needed.", + "switchWarning": "⚠️ You've switched storage types. Clicking “Save configuration” will switch the active storage engine and update existing file driver tags. Use “Start migration” only when physical file copying is needed.", "saveConfig": "Save configuration", "startMigration": "Start migration", "keyPrefix": "Object prefix", @@ -887,7 +841,19 @@ "endTimeDesc": "Select the end time for sync", "noParamsHint": "This task requires no additional parameters. It will be dispatched directly on confirmation.", "start": "Start", - "dispatching": "Dispatching..." + "dispatching": "Dispatching...", + "selectDate": "Select date", + "logPrimaryDb": "Log primary database", + "retentionDays": "Retention days", + "migrationStatus": "Migration status", + "migrating": "Migrating", + "idle": "Idle", + "selectLogDb": "Select log database", + "dbPostgresPrimary": "PostgreSQL (primary)", + "dbSqlitePrimary": "SQLite (primary)", + "dbClickhouse": "ClickHouse", + "taskTypeFilter": "Task type", + "executionRecord": "Execution record" }, "push": { "pageTitle": "Push notifications", @@ -941,7 +907,7 @@ "pushChannelsMulti": "Push channels (multi-select)", "selectConfiguredChannels": "Select configured push channels", "emailPush": "Email push", - "noAvailableChannels": "No channels available. Please create or enable one in \u201cChannel management & settings\u201d first.", + "noAvailableChannels": "No channels available. Please create or enable one in “Channel management & settings” first.", "pushTargets": "Push targets (email and similar channels only)", "pushTargetsPlaceholder": "Separate multiple targets with commas, e.g.: user1@test.com, user2@test.com", "contentTemplate": "Content template (JSON)", @@ -956,8 +922,10 @@ "eventName": "Event name", "eventKey": "Event key", "relatedAsyncTask": "Associated async task", - "noAvailableChannelsEdit": "No channels available. Please create and enable one in \u201cChannel management & settings\u201d first.", - "saveChanges": "Save changes" + "noAvailableChannelsEdit": "No channels available. Please create and enable one in “Channel management & settings” first.", + "saveChanges": "Save changes", + "taskCompleteTitle": "Task completed: {name}", + "taskCompleteContent": "Async task {{task_name}} completed. Status: {{task_status}}, duration: {{task_duration}} ms." }, "settings": { "channelCreateSuccess": "Channel created", @@ -1015,7 +983,9 @@ "pushChannelName": "Push channel name", "testPushTarget": "Test target (template variable $to)", "testPushTargetPlaceholder": "Enter test target identifier, such as Bark token or email", - "sendTest": "Send test" + "sendTest": "Send test", + "fieldRequired": "{label} is required", + "configHelp": "{name} configuration help" }, "histories": { "searchPlaceholder": "Filter by event key...", @@ -1146,7 +1116,10 @@ "confirmClearDesc": "This will permanently delete all cache files from the disk directory (including temporary extracts, processed images, and block files), resetting key count to 0. This cannot be undone and may cause a temporary slowdown for users fetching resources.", "cancel": "Cancel", "clearing": "Clearing...", - "confirmClear": "Confirm clear" + "confirmClear": "Confirm clear", + "unknownError": "Unknown error", + "maxSizePlaceholder": "e.g. 100", + "ttlPlaceholder": "e.g. 60, 0 means never expire" }, "sql": { "sqlExecSuccess": "SQL executed successfully", @@ -1169,7 +1142,11 @@ "queryResultEmpty": "Query result is empty", "sqlExecSuccessTitle": "SQL executed successfully", "affectedRows": "Affected rows: {rows}, duration: {ms}ms.", - "editorReady": "Editor ready. Write SQL above and run to see results." + "editorReady": "Editor ready. Write SQL above and run to see results.", + "unknownExecError": "Unknown execution error", + "clearEditor": "Clear editor", + "dragResize": "Drag to resize", + "execErrorTitle": "SQL execution error:" }, "tableBrowser": { "tableBrowser": "Table browser", @@ -1181,8 +1158,12 @@ "prevPage": "Previous", "nextPage": "Next", "pageIndicator": "Page {current} / {total}", - "noTableSelected": "No table selected or table structure cannot be loaded" - } + "noTableSelected": "No table selected or table structure cannot be loaded", + "fetchTableFailed": "Failed to fetch data for table {table}", + "unknownError": "Unknown error" + }, + "unknownError": "Unknown error", + "exportException": "Export failed" }, "logs": { "pageTitle": "System logs", @@ -1224,7 +1205,12 @@ "unknownGuest": "Unknown / Guest", "requestPath": "Request path", "requestTime": "Request time", - "noLogSelected": "No log entry selected" + "noLogSelected": "No log entry selected", + "copiedToClipboard": "{subject} copied to clipboard", + "noHeaderData": "No header data", + "accessId": "Access ID: {id}", + "copyUserAgent": "Copy User-Agent", + "copyHeaders": "Copy Headers" }, "analytics": { "clickhouseDisabled": "ClickHouse disabled", @@ -1248,13 +1234,19 @@ "unknown": "Unknown", "noNickname": "(no nickname)", "requests": "requests", + "fetchFailed": "Failed to fetch analytics", + "requestVolume": "Requests", + "requestCount": "Request count", + "countTimes": "{count} ({percent}%)", "refresh": "Refresh" }, "appLogs": { "loading": "Loading...", "loadOlderLogs": "Load older logs", "noLogs": "No logs", - "backToLatest": "Back to latest" + "backToLatest": "Back to latest", + "fetchFailed": "Failed to fetch logs", + "loadOlderFailed": "Failed to load older logs" } }, "system": { @@ -1283,7 +1275,8 @@ "system": "System", "other": "Other", "status": "Status", - "info": "Info" + "info": "Info", + "openflare": "OpenFlare" }, "status": { "pageTitle": "System status", @@ -1468,474 +1461,5 @@ } } } - }, - "docs": { - "backToHome": "Back to home", - "sectionCount": "{count} sections", - "lastUpdated": "Last updated", - "copied": "Copied", - "copyMarkdown": "Copy Markdown", - "toc": "Table of contents", - "haveQuestions": "Have questions?", - "contactSupport": "Contact support", - "api": { - "title": "API documentation", - "description": "Complete reference for the Wavelet Platform backend API, covering authentication, users, tokens, and configuration management.", - "field": "Field", - "type": "Type", - "descriptionLabel": "Description", - "endpoint": "Endpoint", - "desc": "Description", - "param": "Parameter", - "required": "Required", - "yes": "Yes", - "no": "No", - "username": "Username", - "password": "Password", - "optional": "Optional", - "default": "Default", - "section1": { - "title": "General conventions", - "desc": "All API responses follow a unified envelope format.", - "responseFormat": "Response format", - "responseFormatDesc": "All endpoints return the following JSON structure:", - "errorMsgDesc": "Empty string on success, human-readable error message on failure", - "dataDesc": "Business data; structure varies by endpoint", - "successExample": "Success response example", - "failExample": "Failure response example", - "failExampleErrorMsg": "Error message", - "authentication": "Authentication", - "authenticationDesc": "The system supports two authentication methods. All protected endpoints require one of them:", - "sessionCredential": "Session credential", - "sessionCredentialDesc": "Returned via Set-Cookie after login. Browsers carry it automatically.", - "accessTokenCredential": "AccessToken credential", - "accessTokenCredentialDesc": "Passed via Header. Suitable for API calls and third-party integrations:", - "supportedHeaders": "Supported headers" - }, - "section2": { - "title": "Authentication endpoints", - "register": "Register", - "registerDesc": "Create a new user account and log in automatically.", - "usernameDesc": "Username, unique, at least 3 characters", - "passwordDesc": "Password, at least 8 characters", - "nicknameDesc": "Nickname (optional)", - "passwordLogin": "Password login", - "passwordLoginDesc": "Log in with email/username + password. Sets cookie on success.", - "logout": "Logout", - "logoutDesc": "Destroy the current session.", - "getProfile": "Get current user info", - "getProfileDesc": "Returns the complete profile of the currently logged-in user." - }, - "section3": { - "title": "Access token management", - "desc": "Manage personal API access tokens (AccessToken) for non-browser API usage.", - "listTokens": "List tokens", - "listTokensDesc": "Get all active access tokens for the current user.", - "createToken": "Create token", - "tokenName": "Token name", - "createTokenDesc": "Create a new access token. Returns the secret once.", - "isAdminDesc": "Whether to grant admin permissions (default false)", - "endpoint": "Endpoint", - "successResponseExample": "Success response example", - "deleteToken": "Delete token", - "deleteTokenDesc": "Revoke the specified access token.", - "rotateToken": "Rotate token", - "rotateTokenDesc": "Generate a new secret for the specified token. The old secret is immediately invalidated." - }, - "section4": { - "title": "System configuration management", - "publicConfig": "Get public configuration", - "publicConfigDesc": "Returns all system configuration items marked as public. No authentication required.", - "responseExample": "Response example", - "adminConfigs": "Admin configuration endpoints", - "adminConfigsDesc": "The following endpoints require admin permissions.", - "getConfigList": "Get configuration list", - "createConfig": "Create configuration", - "updateConfig": "Update configuration", - "deleteConfig": "Delete configuration" - } - }, - "howToUse": { - "title": "User guide", - "description": "Quick start guide for Wavelet Platform, covering authentication, tokens, configuration, tasks, and observability.", - "section1": { - "title": "System architecture overview", - "desc": "Wavelet Platform uses a Go + Next.js frontend-backend separated architecture.", - "architecture": "Tech stack", - "architectureDesc": "Go 1.25+ / Gin / GORM / PostgreSQL / Redis / Asynq", - "authSystem": "Authentication system", - "authSystemDesc": "Supports password login + OIDC third-party authentication", - "accessToken": "Access tokens", - "accessTokenDesc": "Personal Access Tokens for API calls", - "observability": "Observability", - "observabilityDesc": "OpenTelemetry + structured logging" - }, - "section2": { - "title": "Authentication & registration", - "desc": "The system supports multiple authentication methods, configurable via the admin panel.", - "passwordAuth": "Password authentication", - "selfRegister": "Self-registration", - "selfRegisterDesc": "Users can create accounts via the registration page.", - "bcryptDesc": "Passwords are stored with bcrypt encryption.", - "switchControl": "Toggle controls", - "switchControlDesc": "Admins can independently control:", - "oidc": "OIDC third-party login", - "oidcDesc": "Supports configuring any OIDC-compatible authentication source (e.g. Google, GitHub, enterprise SSO).", - "oidcStep1": "Add an authentication source in \u201cSystem settings \u2192 Security\u201d", - "oidcStep2": "Enter Discovery URL, Client ID, Client Secret", - "oidcStep3": "Once enabled, the login page will show the corresponding button" - }, - "section3": { - "title": "Access tokens (AccessToken)", - "desc": "Used for non-browser API calls.", - "generation": "Generation & management", - "oneTimeDisplay": "One-time display", - "oneTimeDisplayDesc": "The secret is only shown once when the token is created.", - "oneTimeDisplayHint": "Copy and save it immediately after creation.", - "secureHash": "Secure storage", - "secureHashDesc": "The system only stores a SHA-256 hash of the token. The original secret cannot be recovered.", - "headerUsage": "Usage", - "headerUsageDesc": "Include the token in the request header:", - "bearerHeader": "Bearer Token method", - "customHeader": "Custom header method" - }, - "section4": { - "title": "System configuration", - "desc": "Manage system configuration dynamically via the admin panel or API.", - "cacheAccel": "Cache acceleration", - "cacheAccelDesc": "Public configuration items support multi-level caching (memory \u2192 Redis \u2192 database), performing well in high-frequency read scenarios.", - "coreConfigs": "Core configuration items", - "siteName": "Site name", - "passwordLoginEnabled": "Password login toggle", - "registrationEnabled": "Registration toggle", - "maxApiKeys": "Max API keys" - }, - "section5": { - "title": "Async tasks", - "desc": "Distributed task queue system based on Asynq.", - "scheduler": "Scheduler", - "schedulerDesc": "Supports Cron expression configuration for scheduled tasks.", - "worker": "Worker", - "workerDesc": "Background worker processes consume and execute tasks." - }, - "section6": { - "title": "Observability", - "desc": "Built-in OpenTelemetry support.", - "tracing": "Distributed tracing", - "tracingDesc": "Supports exporting trace data to Jaeger, Zipkin, and other backends.", - "structuredLog": "Structured logging", - "structuredLogDesc": "High-performance structured logging based on Zap, with level-based output." - } - }, - "terms": { - "title": "Terms of service", - "description": "Please read the following terms of service carefully before using Wavelet Platform.", - "section1": { - "title": "General provisions", - "subject": "Agreement parties", - "subjectDesc": "This agreement is between you and the operator of Wavelet Platform regarding the use of platform services.", - "readCarefully": "Please read carefully", - "readCarefullyDesc": "Please read all contents of this agreement before using the service. If you disagree with any terms, please do not use the platform.", - "composition": "Agreement composition", - "compositionDesc": "This agreement includes the agreement text and all rules that the platform has published or may publish in the future. All rules are integral parts of this agreement and have the same legal effect as the agreement text." - }, - "section2": { - "title": "Service content", - "liability": "Scope of responsibility", - "liabilityDesc": "This platform provides only technical infrastructure and development tool services. Users bear all responsibilities arising from their use of the platform." - }, - "section3": { - "title": "User accounts", - "accountSystem": "Account system", - "accountSystemDesc": "After registering and obtaining an account on this platform, users should properly keep their account and password. Users are responsible for all actions under their account.", - "securityResponsibility": "Security responsibility", - "passwordSecurity": "Password security", - "passwordSecurityDesc": "Users should use sufficiently strong passwords and change them regularly. Losses caused by improper password keeping are the user's responsibility.", - "tokenSecurity": "Token security", - "tokenSecurityDesc": "Access tokens (AccessToken) are equivalent to account credentials and should be properly safeguarded.", - "tokenLeakLiability": "Any losses caused by token leakage are the user's responsibility." - }, - "section4": { - "title": "Usage rules", - "desc": "Users must comply with the following rules when using platform services:", - "redLine": "Prohibited actions", - "nationalSecurity": "Endangering national security", - "nationalSecurityDesc": "Must not use platform services to produce, copy, or publish information that endangers national security.", - "illegalInfo": "Illegal information", - "illegalInfoDesc": "Must not produce, copy, or publish information that violates national laws and regulations.", - "harmfulContent": "Harmful content", - "harmfulContentDesc": "Must not publish information containing harassment, insults, intimidation, or other harmful content.", - "ipInfringement": "Intellectual property infringement", - "ipInfringementDesc": "Must not infringe on others' intellectual property, trade secrets, or other legitimate rights.", - "otherIllegal": "Other illegal acts", - "otherIllegalDesc": "Must not use platform services to engage in other acts that violate laws and regulations.", - "enforcement": "Enforcement", - "enforcementDesc": "If users violate the above rules, the platform reserves the right to take the following actions:", - "enforcementAction": "Warning, suspension, or termination of service" - }, - "section5": { - "title": "Disclaimer", - "basicDisclaimer": "Basic disclaimer", - "basicDisclaimerDesc": "The platform will make every effort to ensure service security and stability, but is not responsible for the following situations:", - "techInterruption": "Technical interruption", - "techInterruptionDesc": "Service interruption or data loss caused by:", - "naturalDisaster": "Natural disasters and other force majeure", - "govAction": "Government actions or legal changes", - "telecomFailure": "Telecom operator failures", - "hacking": "Hacker attacks or computer viruses" - }, - "section6": { - "title": "Governing law & dispute resolution", - "applicableLaw": "Governing law", - "applicableLawDesc": "The conclusion, performance, and interpretation of this agreement are governed by:", - "lawName": "The laws of the People's Republic of China", - "lawScope": "Excluding conflict of laws rules", - "disputeResolution": "Dispute resolution", - "disputeResolutionDesc": "Any dispute arising from or related to this agreement shall be resolved through friendly negotiation. If negotiation fails, either party may file a lawsuit with a court of competent jurisdiction." - } - }, - "privacy": { - "title": "Privacy policy", - "description": "Wavelet Platform values user privacy. This policy explains how we collect, use, and protect your personal information.", - "descriptionRights": "Please read this policy carefully before using our services.", - "section1": { - "title": "Information collection", - "desc": "We collect the following types of information:", - "authInfo": "Authentication information", - "authInfoDesc": "Including username, email address, and encrypted password hash.", - "authInfoNote": "We do not store your plaintext password.", - "logInfo": "Access logs", - "logInfoDesc": "Including IP address, browser type, access time, and request path." - }, - "section2": { - "title": "Information usage", - "desc": "The information we collect is used for the following purposes:", - "storageSecurity": "Storage security", - "storageSecurityDesc": "All user data is encrypted and stored in secure server environments.", - "encryption": "Encryption measures", - "encryptionDesc": "Passwords use bcrypt one-way hash encryption. Tokens use SHA-256 hash storage.", - "accessControl": "Access control", - "accessControlDesc": "Strict permission management ensures only authorized personnel can access user data." - }, - "section3": { - "title": "Purpose of information use", - "desc": "The information we collect is used for the following purposes:", - "identity": "Identity verification", - "identityDesc": "Verify user identity and ensure account security.", - "business": "Business operations", - "businessDesc": "Provide, maintain, and improve our services.", - "riskControl": "Risk control", - "riskControlDesc": "Detect, prevent, and handle security risks and fraudulent activities.", - "prohibited": "Prohibited uses", - "prohibitedDesc": "We commit not to use user information for: selling to third parties, sending unsolicited commercial promotions." - }, - "section4": { - "title": "Information sharing & disclosure", - "sharingPrinciple": "Sharing principle", - "sharingPrincipleDesc": "We do not share personal information with third parties without user consent, except in the following situations:", - "sharingException1": "Required by laws, regulations, or government authorities through legal procedures", - "sharingException2": "Necessary to protect the platform's or the public's legitimate rights", - "transfer": "Information transfer", - "transferDesc": "In the event of a merger, acquisition, or similar transaction, we will require the new holder to continue to be bound by this policy." - }, - "section5": { - "title": "User rights", - "desc": "Users have the following rights regarding their personal information:", - "viewManage": "View and manage", - "viewManageDesc": "Users can view and modify their personal information at any time in their settings.", - "deleteAccount": "Account deletion", - "deleteAccountDesc": "Users can request deletion of their account and all associated personal data." - }, - "section6": { - "title": "Policy updates", - "desc": "We may update this privacy policy from time to time. After updates, we will post a notice on the platform.", - "notification": "Significant changes will be notified via in-app notification or email." - } - } - }, - "home": { - "banner": { - "description": "A general-purpose secondary development platform with built-in user management, authentication, file storage, task scheduling, push notifications, and observability to help developers quickly start new projects.", - "getStarted": "Get started", - "hideHint": "Dismiss" - }, - "navigation": { - "title": "Navigation" - }, - "quickLinks": { - "profile": { - "title": "Profile", - "description": "View and edit your account information" - }, - "apiDocs": { - "title": "API docs", - "description": "Complete API reference" - }, - "userDocs": { - "title": "User guide", - "description": "Quick start guide" - } - }, - "adminLinks": { - "settings": { - "title": "System settings", - "description": "Configure security, operations, and appearance" - }, - "systemConfig": { - "title": "System config", - "description": "Manage key-value system configuration items" - }, - "userManagement": { - "title": "User management", - "description": "View and manage all registered users" - }, - "tasks": { - "title": "Tasks", - "description": "Async task queue and scheduled task scheduling" - }, - "logs": { - "title": "System logs", - "description": "User access logs and application runtime logs" - } - }, - "jumpNow": "Jump now", - "enterNow": "Enter now", - "adminConsole": "Admin console", - "developerSection": { - "heading": "Developer friendly", - "description": "Built-in complete RESTful API docs, Swagger UI, and TypeScript SDK examples for quick integration.", - "features": { - "restful": "Complete RESTful API", - "swagger": "Online Swagger docs", - "typescript": "TypeScript SDK examples", - "examples": "Rich usage examples" - }, - "apiDocs": "View API docs", - "codeComment": "// Quick start \u2014 API call example", - "codeQuickStart": "Quick start" - }, - "hero": { - "subtitle": "Built-in user authentication, file storage, task scheduling,", - "subtitleLine2": "push notifications, and observability to help you quickly launch your next project.", - "getStarted": "Get started", - "learnMore": "Learn more", - "features": { - "readyToUse": "Ready to use", - "highlyExtensible": "Highly extensible", - "industrialGrade": "Industrial grade" - } - }, - "footer": { - "description": "A general-purpose platform scaffold for secondary development", - "product": "Product", - "dashboard": "Dashboard", - "personalSettings": "Personal settings", - "development": "Development", - "quickStart": "Quick start", - "apiDocs": "API docs", - "sourceCode": "Source code", - "community": "Community", - "discussions": "Discussions", - "privacyPolicy": "Privacy policy", - "termsOfService": "Terms of service" - } - }, - "files": { - "uploadSuccess": "File uploaded successfully", - "uploadFailed": "Upload failed", - "deleteSuccess": "File deleted successfully", - "deleteFailed": "Delete failed", - "searchPlaceholder": "Search file name...", - "private": "Private", - "public": "Public", - "uploading": "Uploading...", - "uploadFile": "Upload file", - "noFiles": "No files uploaded yet", - "noMatchingFiles": "No matching files found", - "uploadFirstFile": "Upload your first file. Supports images, videos, documents, and archives.", - "selectFileUpload": "Select file to upload", - "downloadFile": "Download file", - "deleteFile": "Delete file", - "prevPage": "Previous", - "nextPage": "Next", - "confirmDeleteFile": "Confirm file deletion", - "confirmDeleteFileDesc": "Delete file \u201c{name}\u201d? This file cannot be recovered after deletion.", - "cancel": "Cancel", - "confirmDelete": "Confirm delete", - "myFiles": "My files" - }, - "general": { - "manage": { - "saveSuccess": "Saved", - "saveFailed": "Save failed", - "unknownError": "Unknown error", - "deleteSuccess": "Deleted", - "deleteFailed": "Delete failed", - "loading": "Loading", - "noData": "No data", - "configList": "Configuration list", - "confirmDelete": "Confirm delete?", - "confirmDeleteDesc": "This cannot be undone. This will permanently delete the configuration.", - "cancel": "Cancel", - "confirmDeleteAction": "Confirm delete", - "configInfo": "Configuration info", - "selectConfigToView": "Select a configuration to view details", - "updating": "Updating", - "update": "Update", - "actions": "Actions" - }, - "passwordDialog": { - "enterPassword": "Enter password", - "enterPasswordDesc": "For your account security, please enter the 6-digit verification password", - "cancel": "Cancel", - "verifying": "Verifying...", - "confirm": "Confirm" - } - }, - "contexts": { - "adminUsers": { - "userStatusChanged": "User {username} has been {action}", - "enabled": "enabled", - "disabled": "disabled", - "updateStatusFailed": "Failed to update status", - "userCreated": "User {username} created successfully", - "createUserFailed": "Failed to create user", - "userDeleted": "User {username} deleted", - "deleteUserFailed": "Failed to delete user", - "updateUserSuccess": "User information updated", - "updateUserFailed": "Failed to update user information" - }, - "admin": { - "loadConfigFailed": "Failed to load system configuration", - "updateConfigFailed": "Failed to update system configuration" - }, - "user": { - "getUserInfoFailed": "Failed to get user information", - "logoutFailed": "Logout failed" - } - }, - "notFound": { - "description": "Sorry, the page you're looking for seems lost in the digital nebula. Please check the path or return to safety.", - "previousPage": "Previous page", - "home": "Home" - }, - "forbidden": { - "label": "Forbidden", - "description": "You are signed in, but you do not have permission to access this resource. Ask an administrator if you need access.", - "previousPage": "Previous page", - "home": "Home" - }, - "providers": { - "titleUpdater": { - "login": "Login", - "register": "Register", - "admin": "Admin", - "dashboard": "Dashboard", - "forbidden": "Forbidden" - } - }, - "metadata": { - "title": "Wavelet Platform", - "description": "A general-purpose platform scaffold for secondary development" } } diff --git a/frontend/messages/zh-CN.json b/frontend/messages/fragments/admin.zh-CN.json similarity index 72% rename from frontend/messages/zh-CN.json rename to frontend/messages/fragments/admin.zh-CN.json index d6b84f32..6eecc892 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/fragments/admin.zh-CN.json @@ -1,105 +1,4 @@ { - "common": { - "save": "保存", - "cancel": "取消", - "confirm": "确认", - "delete": "删除", - "loading": "加载中...", - "retry": "重试", - "optional": "(可选)", - "settings": "设置", - "search": "搜索", - "unknownError": "发生错误,请重试", - "loadFailed": "加载失败", - "previousPage": "上一页", - "nextPage": "下一页" - }, - "layout": { - "nav": { - "home": "首页", - "myFiles": "我的文件", - "users": "用户管理", - "tasks": "任务管理", - "storage": "存储管理", - "database": "数据管理", - "push": "通知推送", - "messageGateway": "消息通道", - "logs": "系统日志", - "system": "系统配置", - "adminSettings": "系统设置", - "demo": "规范示例", - "apiDocs": "接口文档", - "usageDocs": "使用文档" - }, - "groups": { - "admin": "管理", - "docs": "文档库" - }, - "user": { - "adminRole": "系统管理员", - "userRole": "普通用户", - "notLoggedIn": "未登录", - "syncing": "正在同步账户", - "profile": "我的资料", - "settings": "设置", - "help": "使用帮助", - "logout": "退出登录" - }, - "logoutDialog": { - "title": "确认登出", - "description": "您确定要登出当前账户吗?", - "loggingOut": "正在登出,请稍候...", - "confirm": "确认登出", - "inProgress": "登出中...", - "failed": "登出失败", - "failedDescription": "登出时发生错误,请重试" - }, - "header": { - "notifications": "通知", - "settings": "设置", - "search": "搜索", - "toggleFullWidth": "切换全宽", - "toggleTheme": "主题切换", - "changePasswordBanner": "为了您的账号安全,请立即修改密码!", - "changePasswordAction": "去修改密码", - "bannerTitle": "最新通知", - "bannerLink": "查看详情", - "dismiss": "关闭" - }, - "search": { - "categoryPage": "页面", - "categoryFeature": "功能", - "categorySetting": "设置", - "categoryAdmin": "管理", - "tipPrefix": "Tips:", - "tipSlash": "还可以使用 / 来打开此界面", - "tipArrow": "来切换选中项", - "tipNewTab": "在新标签页打开", - "tipDidYouKnow": "你知道吗:搜索功能还在持续升级中", - "searchPlaceholder": "搜索页面和功能...", - "noResults": "没有找到相关内容,换个词试试?", - "openInNewTab": "在新标签页打开", - "open": "打开", - "closeSearch": "关闭搜索界面" - }, - "error": { - "loadFailed": "加载失败", - "retry": "重试", - "unknownError": "发生未知错误,请稍后重试", - "viewDetails": "查看详细错误信息", - "unknownErrorShort": "发生未知错误" - }, - "loading": { - "loading": "加载中", - "loadingDesc": "正在获取活动数据..." - }, - "empty": { - "noData": "暂无数据", - "noContent": "当前没有任何记录" - }, - "toggleSidebar": "切换侧边栏", - "mainNavigation": "主导航" - }, "auth": { "login": { "title": "登录到您的账号", @@ -125,7 +24,14 @@ "registerNow": "立即注册", "codeSent": "登录验证码已发送至您的邮箱,请注意查收", "codeTipMasked": "已向您的安全邮箱 {email} 发送了登录验证码。", - "codeTipGeneric": "请输入您的登录验证码。" + "codeTipGeneric": "请输入您的登录验证码。", + "needBind": "您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。", + "bindSuccess": "绑定成功", + "checkingSession": "正在检查登录状态", + "checkingSessionDesc": "请稍候,我们正在确认当前会话...", + "redirecting": "正在跳转至控制台...", + "verifyingCredentials": "正在验证凭据", + "establishingSession": "请稍候,我们正在为您建立安全会话..." }, "otp": { "title": "验证您的登录", @@ -414,6 +320,7 @@ "confirmDelete": "确认删除", "deleting": "删除中...", "noAuthSources": "暂无配置的认证源,点击上方按钮新增", + "loginCaptchaEnabled": "登录人机验证", "captchaConfig": "人机验证配置 (Cap CAPTCHA)", "captchaConfigDesc": "配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击", "autoStartSolving": "打开页面后自动开始计算", @@ -485,7 +392,32 @@ "menuDisplayManagementDesc": "配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。", "notHideable": "不可隐藏", "securityTipTitle": "安全提示:", - "securityTipDesc": "为了防止管理员在关闭\"系统设置\"后导致无法重新访问此配置页,系统限制了\"系统设置\"的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。" + "securityTipDesc": "为了防止管理员在关闭\"系统设置\"后导致无法重新访问此配置页,系统限制了\"系统设置\"的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。", + "groupBusiness": "业务菜单", + "groupAdmin": "管理菜单", + "groupDocs": "文档菜单", + "descDashboard": "查看边缘节点请求、访客与流量等多维度图表看板", + "descNodes": "注册与管理反向代理边缘节点服务状态", + "descProxyRoutes": "配置和发布反代路由规则、负载均衡与源站绑定", + "descWebsites": "配置站点根域名并查看关联网站详情", + "descCertificates": "管理和下发网站 SSL/TLS 证书", + "descDnsAccounts": "配置 Cloudflare/Alidns 等 DNS 服务商 API 凭证", + "descOrigins": "集中维护和管理后端业务源站服务器组", + "descWaf": "配置 Web 应用防火墙自定义规则与频率限制", + "descIpGroups": "管理黑白名单 IP 集合,用于 WAF 规则过滤", + "descPages": "托管和发布静态网页项目,支持自动关联域名", + "descConfigVersions": "查看规则快照版本并进行配置热更新分发与回滚", + "descAccessLogs": "多维度实时浏览边缘节点请求访问明细", + "descPerformance": "配置缓存、图片压缩等页面性能加速策略", + "descUsers": "查看和管理系统用户列表及其状态", + "descTasks": "查看和调度系统异步及定时任务", + "descStorage": "管理系统文件存储和清理无用文件", + "descDatabase": "监控物理数据库状态、分页浏览表数据及交互式 SQL 查询", + "descPush": "配置和发送系统通知及推送消息", + "descLogs": "查看异步任务执行日志和系统运行情况", + "descSystem": "管理和维护系统基础键值对配置", + "descAdminSettings": "配置安全验证、邮箱服务及目录显示", + "descUsageDocs": "面向开发与运营的部署使用指南" }, "templates": { "templateCreated": "通知模板已成功创建", @@ -559,7 +491,21 @@ "selectBusinessType": "选择业务类型...", "allTypesAdded": "所有类型已添加", "currentAuthFreeList": "当前免鉴权列表", - "whitelistEmpty": "白名单已空,所有类型文件的访问都将需要登录。" + "whitelistEmpty": "白名单已空,所有类型文件的访问都将需要登录。", + "typeAvatar": "头像 (avatar)", + "typeAttachment": "附件 (attachment)", + "typeDoc": "文档 (doc)", + "typeGeneric": "通用 (generic)", + "logRetentionTitle": "日志保留时间", + "logRetentionDesc": "配置各日志数据库的日志保留天数,切换日志数据库后自动按对应配置清理过期日志。", + "days": "天", + "save": "保存", + "logRetentionUpdated": "日志保留时间已更新", + "logRetentionUpdateFailed": "更新日志保留时间失败", + "retentionInvalid": "{label}必须为大于等于 1 的整数", + "retentionPostgresDesc": "访问日志与可观测指标统一保留天数", + "retentionSqliteDesc": "SQLite 日志保留天数", + "retentionClickhouseDesc": "ClickHouse 日志保留天数" }, "authSourceModal": { "authSourceSaved": "认证源已保存", @@ -611,7 +557,14 @@ "sizeDistTitle": "文件占用容量分布", "sizeDistDesc": "各种格式类型的文件大小占比", "noSizeData": "暂无容量数据", - "maxSizeHint": "文件类型中占用存储最大的是「{name}」,共消耗了 {size} 存储容量。" + "maxSizeHint": "文件类型中占用存储最大的是「{name}」,共消耗了 {size} 存储容量。", + "catImages": "图片", + "catVideos": "视频", + "catAudio": "音频", + "catDocuments": "文档", + "catArchives": "压缩包", + "catOthers": "其他", + "none": "无" }, "list": { "searchPlaceholder": "搜索文件名...", @@ -656,7 +609,8 @@ "cancel": "取消", "confirmDelete": "确认删除", "deleteSuccess": "文件已删除", - "deleteFailed": "删除失败" + "deleteFailed": "删除失败", + "extensionFile": "{ext} 文件" }, "storage": { "maintenanceMode": "存储维护模式", @@ -887,7 +841,19 @@ "endTimeDesc": "选择同步的结束时间点", "noParamsHint": "此任务无需额外配置参数,确认后将直接下发。", "start": "启动", - "dispatching": "下发中" + "dispatching": "下发中", + "selectDate": "选择日期", + "logPrimaryDb": "日志主库", + "retentionDays": "保留天数", + "migrationStatus": "迁移状态", + "migrating": "迁移中", + "idle": "空闲", + "selectLogDb": "选择日志数据库", + "dbPostgresPrimary": "PostgreSQL(主库)", + "dbSqlitePrimary": "SQLite(主库)", + "dbClickhouse": "ClickHouse", + "taskTypeFilter": "任务类型", + "executionRecord": "执行记录" }, "push": { "pageTitle": "通知推送管理", @@ -957,7 +923,9 @@ "eventKey": "事件键 (Key)", "relatedAsyncTask": "关联异步任务", "noAvailableChannelsEdit": "暂无可用渠道,请先在\"通道管理与设置\"中创建并启用。", - "saveChanges": "保存修改" + "saveChanges": "保存修改", + "taskCompleteTitle": "任务完成: {name}", + "taskCompleteContent": "异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。" }, "settings": { "channelCreateSuccess": "通道创建成功", @@ -1015,7 +983,9 @@ "pushChannelName": "推送通道名称", "testPushTarget": "测试推送目标 (对应模板变量 $to)", "testPushTargetPlaceholder": "请输入测试推送接收人/目标标识,如 Bark Token、邮箱等", - "sendTest": "发送测试" + "sendTest": "发送测试", + "fieldRequired": "{label}不能为空", + "configHelp": "{name}配置说明" }, "histories": { "searchPlaceholder": "输入事件键过滤...", @@ -1146,7 +1116,10 @@ "confirmClearDesc": "该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。", "cancel": "取消", "clearing": "清理中...", - "confirmClear": "确认清空" + "confirmClear": "确认清空", + "unknownError": "未知错误", + "maxSizePlaceholder": "例如 100", + "ttlPlaceholder": "例如 60,0 表示永不过期" }, "sql": { "sqlExecSuccess": "SQL 执行成功", @@ -1169,7 +1142,11 @@ "queryResultEmpty": "查询结果为空", "sqlExecSuccessTitle": "SQL 执行成功", "affectedRows": "受影响行数: {rows} 行,耗时 {ms} 毫秒。", - "editorReady": "编辑器就绪,请在上方编写 SQL 并运行查看结果" + "editorReady": "编辑器就绪,请在上方编写 SQL 并运行查看结果", + "unknownExecError": "未知执行错误", + "clearEditor": "清空编辑器", + "dragResize": "拖动调整大小", + "execErrorTitle": "SQL 执行报错 (Error):" }, "tableBrowser": { "tableBrowser": "数据表浏览器", @@ -1181,8 +1158,12 @@ "prevPage": "上一页", "nextPage": "下一页", "pageIndicator": "第 {current} / {total} 页", - "noTableSelected": "未选择数据表或表结构无法加载" - } + "noTableSelected": "未选择数据表或表结构无法加载", + "fetchTableFailed": "获取数据表 {table} 数据失败", + "unknownError": "未知错误" + }, + "unknownError": "未知错误", + "exportException": "导出异常" }, "logs": { "pageTitle": "系统日志", @@ -1224,7 +1205,12 @@ "unknownGuest": "未知/游客", "requestPath": "请求路径", "requestTime": "请求时间", - "noLogSelected": "未选中日志明细" + "noLogSelected": "未选中日志明细", + "copiedToClipboard": "{subject}已复制到剪贴板", + "noHeaderData": "暂无头部数据", + "accessId": "访问 ID: {id}", + "copyUserAgent": "复制 User-Agent", + "copyHeaders": "复制 Headers" }, "analytics": { "clickhouseDisabled": "ClickHouse 未启用", @@ -1248,13 +1234,19 @@ "unknown": "未知", "noNickname": "(无昵称)", "requests": "次请求", + "fetchFailed": "获取数据统计失败", + "requestVolume": "请求量", + "requestCount": "请求次数", + "countTimes": "{count} 次 ({percent}%)", "refresh": "刷新" }, "appLogs": { "loading": "加载中...", "loadOlderLogs": "加载更早日志", "noLogs": "暂无日志", - "backToLatest": "回到最新" + "backToLatest": "回到最新", + "fetchFailed": "获取日志失败", + "loadOlderFailed": "加载更早日志失败" } }, "system": { @@ -1283,7 +1275,8 @@ "system": "系统设置", "other": "其他设置", "status": "系统状态", - "info": "系统信息" + "info": "系统信息", + "openflare": "OpenFlare" }, "status": { "pageTitle": "系统状态", @@ -1468,474 +1461,5 @@ } } } - }, - "docs": { - "backToHome": "返回首页", - "sectionCount": "共 {count} 个章节", - "lastUpdated": "最后更新", - "copied": "已复制", - "copyMarkdown": "复制 Markdown", - "toc": "目录", - "haveQuestions": "还有疑问?", - "contactSupport": "联系客服支持", - "api": { - "title": "接口文档", - "description": "Wavelet Platform 后端 API 完整参考,涵盖认证、用户、令牌与配置管理。", - "field": "字段", - "type": "类型", - "descriptionLabel": "说明", - "endpoint": "端点", - "desc": "描述", - "param": "参数", - "required": "必需", - "yes": "是", - "no": "否", - "username": "用户名", - "password": "密码", - "optional": "可选", - "default": "默认", - "section1": { - "title": "通用约定", - "desc": "所有 API 响应遵循统一信封格式。", - "responseFormat": "响应格式", - "responseFormatDesc": "所有接口均返回以下 JSON 结构:", - "errorMsgDesc": "成功时为空字符串,失败时为可读错误信息", - "dataDesc": "业务数据,结构因接口而异", - "successExample": "成功响应示例", - "failExample": "失败响应示例", - "failExampleErrorMsg": "错误信息", - "authentication": "认证方式", - "authenticationDesc": "系统支持两种认证方式,所有受保护的接口均需携带其一:", - "sessionCredential": "Session 凭证", - "sessionCredentialDesc": "登录后由服务端 Set-Cookie 返回,浏览器自动携带。", - "accessTokenCredential": "AccessToken 凭证", - "accessTokenCredentialDesc": "通过 Header 传递,适用于 API 调用与第三方集成:", - "supportedHeaders": "支持的 Header" - }, - "section2": { - "title": "认证接口", - "register": "注册", - "registerDesc": "创建新用户账号并自动登录。", - "usernameDesc": "用户名,唯一,至少 3 位", - "passwordDesc": "密码,至少 8 位", - "nicknameDesc": "昵称(可选)", - "passwordLogin": "密码登录", - "passwordLoginDesc": "使用邮箱/用户名 + 密码登录,成功后 Set-Cookie。", - "logout": "登出", - "logoutDesc": "销毁当前会话。", - "getProfile": "获取当前用户信息", - "getProfileDesc": "返回当前已登录用户的完整档案。" - }, - "section3": { - "title": "访问令牌管理", - "desc": "管理个人 API 访问令牌(AccessToken),用于无浏览器场景的 API 调用。", - "listTokens": "列出令牌", - "listTokensDesc": "获取当前用户的所有活跃访问令牌。", - "createToken": "创建令牌", - "tokenName": "令牌名称", - "createTokenDesc": "创建新的访问令牌,返回一次性密钥。", - "isAdminDesc": "是否授予管理员权限(默认 false)", - "endpoint": "端点", - "successResponseExample": "成功响应示例", - "deleteToken": "删除令牌", - "deleteTokenDesc": "撤销指定的访问令牌。", - "rotateToken": "轮换令牌", - "rotateTokenDesc": "为指定令牌生成新密钥,旧密钥立即失效。" - }, - "section4": { - "title": "系统配置管理", - "publicConfig": "获取公开配置", - "publicConfigDesc": "返回所有标记为公开的系统配置项,无需认证。", - "responseExample": "响应示例", - "adminConfigs": "管理员配置接口", - "adminConfigsDesc": "以下接口需要管理员权限。", - "getConfigList": "获取配置列表", - "createConfig": "创建配置", - "updateConfig": "更新配置", - "deleteConfig": "删除配置" - } - }, - "howToUse": { - "title": "使用文档", - "description": "Wavelet Platform 快速上手指南,涵盖认证、令牌、配置、任务与可观测性。", - "section1": { - "title": "系统架构概览", - "desc": "Wavelet Platform 采用 Go + Next.js 前后端分离架构。", - "architecture": "技术栈", - "architectureDesc": "Go 1.25+ / Gin / GORM / PostgreSQL / Redis / Asynq", - "authSystem": "认证系统", - "authSystemDesc": "支持密码登录 + OIDC 第三方认证", - "accessToken": "访问令牌", - "accessTokenDesc": "用于 API 调用的 Personal Access Token", - "observability": "可观测性", - "observabilityDesc": "OpenTelemetry + 结构化日志" - }, - "section2": { - "title": "认证与注册", - "desc": "系统支持多种认证方式,可通过管理后台灵活配置。", - "passwordAuth": "密码认证", - "selfRegister": "自主注册", - "selfRegisterDesc": "用户可通过注册页面创建账号。", - "bcryptDesc": "密码使用 bcrypt 加密存储。", - "switchControl": "开关控制", - "switchControlDesc": "管理员可在后台独立控制:", - "oidc": "OIDC 第三方登录", - "oidcDesc": "支持配置任意 OIDC 兼容的认证源(如 Google、GitHub、企业 SSO)。", - "oidcStep1": "在「系统设置 → 安全设置」中新增认证源", - "oidcStep2": "填写 Discovery URL、Client ID、Client Secret", - "oidcStep3": "启用后,登录页将自动显示对应入口" - }, - "section3": { - "title": "访问令牌 (AccessToken)", - "desc": "用于无浏览器场景的 API 调用。", - "generation": "生成与管理", - "oneTimeDisplay": "一次性显示", - "oneTimeDisplayDesc": "创建令牌后,密钥仅显示一次。", - "oneTimeDisplayHint": "请务必在创建后立即复制保存。", - "secureHash": "安全存储", - "secureHashDesc": "系统仅存储令牌的 SHA-256 哈希,原始密钥不可恢复。", - "headerUsage": "使用方式", - "headerUsageDesc": "在请求 Header 中携带令牌:", - "bearerHeader": "Bearer Token 方式", - "customHeader": "自定义 Header 方式" - }, - "section4": { - "title": "系统配置", - "desc": "通过管理后台或 API 动态管理系统配置。", - "cacheAccel": "缓存加速", - "cacheAccelDesc": "公开配置项支持多级缓存(内存 → Redis → 数据库),高频读取场景下性能优异。", - "coreConfigs": "核心配置项", - "siteName": "站点名称", - "passwordLoginEnabled": "密码登录开关", - "registrationEnabled": "注册开关", - "maxApiKeys": "最大 API Key 数量" - }, - "section5": { - "title": "异步任务", - "desc": "基于 Asynq 的分布式任务队列系统。", - "scheduler": "定时任务 (Scheduler)", - "schedulerDesc": "支持 Cron 表达式配置定时任务。", - "worker": "Worker", - "workerDesc": "后台 Worker 进程负责消费和执行任务。" - }, - "section6": { - "title": "可观测性", - "desc": "内置 OpenTelemetry 支持。", - "tracing": "链路追踪", - "tracingDesc": "支持将 Trace 数据导出到 Jaeger、Zipkin 等后端。", - "structuredLog": "结构化日志", - "structuredLogDesc": "基于 Zap 的高性能结构化日志,支持按级别输出。" - } - }, - "terms": { - "title": "服务协议", - "description": "使用 Wavelet Platform 前,请仔细阅读以下服务条款。", - "section1": { - "title": "总则", - "subject": "协议主体", - "subjectDesc": "本协议是您与 Wavelet Platform 运营方之间关于使用平台服务所订立的协议。", - "readCarefully": "请仔细阅读", - "readCarefullyDesc": "请您在使用服务前仔细阅读本协议全部内容。如您不同意本协议的任何条款,请勿使用本平台服务。", - "composition": "协议构成", - "compositionDesc": "本协议正文包括协议正文、平台已经发布或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。" - }, - "section2": { - "title": "服务内容", - "liability": "责任范围", - "liabilityDesc": "本平台仅提供技术基础设施和开发工具服务。用户应自行承担使用平台过程中产生的所有责任。" - }, - "section3": { - "title": "用户账号", - "accountSystem": "账号体系", - "accountSystemDesc": "用户在本平台注册并获得账号后,应妥善保管其账号和密码。用户应对其账号项下的所有行为负责。", - "securityResponsibility": "安全责任", - "passwordSecurity": "密码安全", - "passwordSecurityDesc": "用户应使用安全强度足够的密码,并定期更换。因用户密码保管不当导致的损失,由用户自行承担。", - "tokenSecurity": "令牌安全", - "tokenSecurityDesc": "访问令牌(AccessToken)等同于账号凭证,用户应妥善保管。", - "tokenLeakLiability": "因令牌泄露导致的任何损失,由用户自行承担。" - }, - "section4": { - "title": "使用规范", - "desc": "用户在使用本平台服务时,必须遵守以下规范:", - "redLine": "禁止行为红线", - "nationalSecurity": "危害国家安全", - "nationalSecurityDesc": "不得利用平台服务制作、复制、发布含有危害国家安全的信息。", - "illegalInfo": "违法信息", - "illegalInfoDesc": "不得制作、复制、发布违反国家法律法规的信息。", - "harmfulContent": "有害内容", - "harmfulContentDesc": "不得发布含有骚扰、侮辱、恐吓或其他有害内容的信息。", - "ipInfringement": "知识产权侵权", - "ipInfringementDesc": "不得侵犯他人知识产权、商业秘密等合法权益。", - "otherIllegal": "其他违法行为", - "otherIllegalDesc": "不得利用平台服务从事其他违反法律法规的行为。", - "enforcement": "违规处理", - "enforcementDesc": "如用户违反上述规范,平台有权采取以下措施:", - "enforcementAction": "警告、暂停或终止服务" - }, - "section5": { - "title": "免责声明", - "basicDisclaimer": "基本免责", - "basicDisclaimerDesc": "平台将尽最大努力确保服务的安全性和稳定性,但不对以下情形承担责任:", - "techInterruption": "技术中断", - "techInterruptionDesc": "因以下原因导致的服务中断或数据丢失:", - "naturalDisaster": "自然灾害等不可抗力", - "govAction": "政府行为或法律法规变更", - "telecomFailure": "电信运营商故障", - "hacking": "黑客攻击或计算机病毒" - }, - "section6": { - "title": "法律适用与争议解决", - "applicableLaw": "适用法律", - "applicableLawDesc": "本协议的签订、履行和解释均适用:", - "lawName": "中华人民共和国法律", - "lawScope": "不含冲突法规则", - "disputeResolution": "争议解决", - "disputeResolutionDesc": "因本协议引起的或与本协议有关的任何争议,双方应友好协商解决。协商不成的,任何一方均有权向有管辖权的人民法院提起诉讼。" - } - }, - "privacy": { - "title": "隐私政策", - "description": "Wavelet Platform 重视用户隐私保护。本政策说明我们如何收集、使用和保护您的个人信息。", - "descriptionRights": "请在使用我们的服务前仔细阅读本政策。", - "section1": { - "title": "信息收集", - "desc": "我们收集以下类型的信息:", - "authInfo": "认证信息", - "authInfoDesc": "包括用户名、邮箱地址、加密后的密码哈希。", - "authInfoNote": "我们不会存储您的明文密码。", - "logInfo": "访问日志", - "logInfoDesc": "包括 IP 地址、浏览器类型、访问时间、请求路径。" - }, - "section2": { - "title": "信息使用", - "desc": "我们收集的信息用于以下目的:", - "storageSecurity": "存储安全", - "storageSecurityDesc": "所有用户数据均加密存储于安全的服务器环境中。", - "encryption": "加密措施", - "encryptionDesc": "密码使用 bcrypt 单向哈希加密,令牌使用 SHA-256 哈希存储。", - "accessControl": "访问控制", - "accessControlDesc": "严格的权限管理机制确保只有授权人员才能访问用户数据。" - }, - "section3": { - "title": "信息使用目的", - "desc": "我们收集的信息将用于以下目的:", - "identity": "身份验证", - "identityDesc": "验证用户身份,确保账号安全。", - "business": "业务运营", - "businessDesc": "提供、维护和改进我们的服务。", - "riskControl": "风险控制", - "riskControlDesc": "检测、预防和处理安全风险及欺诈行为。", - "prohibited": "禁止用途", - "prohibitedDesc": "我们承诺不会将用户信息用于以下用途:出售给第三方、发送未经许可的商业推广。" - }, - "section4": { - "title": "信息共享与披露", - "sharingPrinciple": "共享原则", - "sharingPrincipleDesc": "未经用户同意,我们不会向第三方共享用户个人信息,但以下情况除外:", - "sharingException1": "法律法规要求、政府机关依法定程序调取", - "sharingException2": "为保护平台或公众的合法权益所必需", - "transfer": "信息转让", - "transferDesc": "如平台发生合并、收购等情形,我们会要求新的持有者继续受本政策约束。" - }, - "section5": { - "title": "用户权利", - "desc": "用户对其个人信息享有以下权利:", - "viewManage": "查看与管理", - "viewManageDesc": "用户可随时在个人设置中查看和修改其个人信息。", - "deleteAccount": "账号删除", - "deleteAccountDesc": "用户可申请删除其账号及关联的所有个人数据。" - }, - "section6": { - "title": "政策更新", - "desc": "我们可能会不时更新本隐私政策。政策更新后,我们会在平台上发布通知。", - "notification": "重大变更将通过站内通知或邮件方式告知用户。" - } - } - }, - "home": { - "banner": { - "description": "这是一个通用的二次开发基础平台,内置用户管理、认证鉴权、文件存储、任务调度、通知推送和可观测性等常用功能模块,帮助开发者快速启动新项目。", - "getStarted": "快速开始", - "hideHint": "收起提示" - }, - "navigation": { - "title": "导航" - }, - "quickLinks": { - "profile": { - "title": "个人资料", - "description": "查看和编辑您的账户信息" - }, - "apiDocs": { - "title": "接口文档", - "description": "API 接口完整参考" - }, - "userDocs": { - "title": "使用文档", - "description": "快速上手指南" - } - }, - "adminLinks": { - "settings": { - "title": "系统设置", - "description": "配置系统安全、业务与外观参数" - }, - "systemConfig": { - "title": "系统配置", - "description": "管理 Key-Value 系统配置项" - }, - "userManagement": { - "title": "用户管理", - "description": "查看和管理所有注册用户" - }, - "tasks": { - "title": "任务管理", - "description": "异步任务队列与定时任务调度" - }, - "logs": { - "title": "系统日志", - "description": "用户访问日志与应用运行日志" - } - }, - "jumpNow": "立即跳转", - "enterNow": "立即进入", - "adminConsole": "管理后台", - "developerSection": { - "heading": "开发者友好", - "description": "内置完整的 RESTful API 文档、Swagger UI 与 TypeScript SDK 示例,帮助您快速集成。", - "features": { - "restful": "完整 RESTful API", - "swagger": "在线 Swagger 文档", - "typescript": "TypeScript SDK 示例", - "examples": "丰富的使用示例" - }, - "apiDocs": "查看接口文档", - "codeComment": "// 快速开始 — API 调用示例", - "codeQuickStart": "快速开始" - }, - "hero": { - "subtitle": "内置用户认证、文件存储、任务调度", - "subtitleLine2": "通知推送与可观测性,帮助您快速启动下一个项目。", - "getStarted": "快速开始", - "learnMore": "了解更多", - "features": { - "readyToUse": "开箱即用", - "highlyExtensible": "高度可扩展", - "industrialGrade": "工业级品质" - } - }, - "footer": { - "description": "通用二次开发标准范式平台", - "product": "产品", - "dashboard": "控制台", - "personalSettings": "个人设置", - "development": "开发", - "quickStart": "快速开始", - "apiDocs": "接口文档", - "sourceCode": "源代码", - "community": "社区", - "discussions": "Discussions", - "privacyPolicy": "隐私政策", - "termsOfService": "服务条款" - } - }, - "files": { - "uploadSuccess": "文件上传成功", - "uploadFailed": "上传失败", - "deleteSuccess": "文件已成功删除", - "deleteFailed": "删除失败", - "searchPlaceholder": "搜索文件名...", - "private": "私有", - "public": "公开", - "uploading": "正在上传...", - "uploadFile": "上传文件", - "noFiles": "还没有上传文件", - "noMatchingFiles": "未搜索到匹配的文件", - "uploadFirstFile": "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。", - "selectFileUpload": "选择文件上传", - "downloadFile": "下载文件", - "deleteFile": "删除文件", - "prevPage": "上一页", - "nextPage": "下一页", - "confirmDeleteFile": "确认删除文件", - "confirmDeleteFileDesc": "确定要删除文件「{name}」吗?删除后此文件将无法恢复。", - "cancel": "取消", - "confirmDelete": "确认删除", - "myFiles": "我的文件" - }, - "general": { - "manage": { - "saveSuccess": "保存成功", - "saveFailed": "保存失败", - "unknownError": "未知错误", - "deleteSuccess": "删除成功", - "deleteFailed": "删除失败", - "loading": "加载中", - "noData": "暂无数据", - "configList": "配置列表", - "confirmDelete": "确认删除?", - "confirmDeleteDesc": "此操作无法撤销。这将永久删除该配置。", - "cancel": "取消", - "confirmDeleteAction": "确认删除", - "configInfo": "配置信息", - "selectConfigToView": "请选择配置查看详情", - "updating": "更新中", - "update": "更新", - "actions": "操作" - }, - "passwordDialog": { - "enterPassword": "请输入密码", - "enterPasswordDesc": "为了您的账户安全,请输入6位认证密码进行验证", - "cancel": "取消", - "verifying": "验证中...", - "confirm": "确认" - } - }, - "contexts": { - "adminUsers": { - "userStatusChanged": "已{action}用户 {username}", - "enabled": "启用", - "disabled": "禁用", - "updateStatusFailed": "更新状态失败", - "userCreated": "已成功创建用户 {username}", - "createUserFailed": "创建用户失败", - "userDeleted": "已删除用户 {username}", - "deleteUserFailed": "删除用户失败", - "updateUserSuccess": "更新用户信息成功", - "updateUserFailed": "更新用户信息失败" - }, - "admin": { - "loadConfigFailed": "加载系统配置失败", - "updateConfigFailed": "更新系统配置失败" - }, - "user": { - "getUserInfoFailed": "获取用户信息失败", - "logoutFailed": "登出失败" - } - }, - "notFound": { - "description": "抱歉,您访问的页面似已迷失在数字星云中。请检查路径或返回安全地带。", - "previousPage": "上一页", - "home": "首页" - }, - "forbidden": { - "label": "Forbidden", - "description": "您已登录,但没有访问该资源的权限。如需开通,请联系管理员。", - "previousPage": "上一页", - "home": "首页" - }, - "providers": { - "titleUpdater": { - "login": "登录", - "register": "注册", - "admin": "后台管理", - "dashboard": "控制台", - "forbidden": "权限不足" - } - }, - "metadata": { - "title": "Wavelet Platform", - "description": "通用二次开发标准范式平台" } } diff --git a/frontend/messages/fragments/core.en.json b/frontend/messages/fragments/core.en.json new file mode 100644 index 00000000..e8aa32f5 --- /dev/null +++ b/frontend/messages/fragments/core.en.json @@ -0,0 +1,479 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "loading": "Loading...", + "retry": "Retry", + "optional": "(optional)", + "settings": "Settings", + "search": "Search", + "unknownError": "Something went wrong. Please try again.", + "loadFailed": "Load failed", + "previousPage": "Previous page", + "nextPage": "Next page" + }, + "contexts": { + "adminUsers": { + "userStatusChanged": "User {username} has been {action}", + "enabled": "enabled", + "disabled": "disabled", + "updateStatusFailed": "Failed to update status", + "userCreated": "User {username} created successfully", + "createUserFailed": "Failed to create user", + "userDeleted": "User {username} deleted", + "deleteUserFailed": "Failed to delete user", + "updateUserSuccess": "User information updated", + "updateUserFailed": "Failed to update user information" + }, + "admin": { + "loadConfigFailed": "Failed to load system configuration", + "updateConfigFailed": "Failed to update system configuration" + }, + "user": { + "getUserInfoFailed": "Failed to get user information", + "logoutFailed": "Logout failed" + } + }, + "docs": { + "backToHome": "Back to home", + "sectionCount": "{count} sections", + "lastUpdated": "Last updated", + "copied": "Copied", + "copyMarkdown": "Copy Markdown", + "toc": "Table of contents", + "haveQuestions": "Have questions?", + "contactSupport": "Contact support", + "api": { + "title": "API documentation", + "description": "Complete reference for the Wavelet Platform backend API, covering authentication, users, tokens, and configuration management.", + "field": "Field", + "type": "Type", + "descriptionLabel": "Description", + "endpoint": "Endpoint", + "desc": "Description", + "param": "Parameter", + "required": "Required", + "yes": "Yes", + "no": "No", + "username": "Username", + "password": "Password", + "optional": "Optional", + "default": "Default", + "section1": { + "title": "General conventions", + "desc": "All API responses follow a unified envelope format.", + "responseFormat": "Response format", + "responseFormatDesc": "All endpoints return the following JSON structure:", + "errorMsgDesc": "Empty string on success, human-readable error message on failure", + "dataDesc": "Business data; structure varies by endpoint", + "successExample": "Success response example", + "failExample": "Failure response example", + "failExampleErrorMsg": "Error message", + "authentication": "Authentication", + "authenticationDesc": "The system supports two authentication methods. All protected endpoints require one of them:", + "sessionCredential": "Session credential", + "sessionCredentialDesc": "Returned via Set-Cookie after login. Browsers carry it automatically.", + "accessTokenCredential": "AccessToken credential", + "accessTokenCredentialDesc": "Passed via Header. Suitable for API calls and third-party integrations:", + "supportedHeaders": "Supported headers" + }, + "section2": { + "title": "Authentication endpoints", + "register": "Register", + "registerDesc": "Create a new user account and log in automatically.", + "usernameDesc": "Username, unique, at least 3 characters", + "passwordDesc": "Password, at least 8 characters", + "nicknameDesc": "Nickname (optional)", + "passwordLogin": "Password login", + "passwordLoginDesc": "Log in with email/username + password. Sets cookie on success.", + "logout": "Logout", + "logoutDesc": "Destroy the current session.", + "getProfile": "Get current user info", + "getProfileDesc": "Returns the complete profile of the currently logged-in user." + }, + "section3": { + "title": "Access token management", + "desc": "Manage personal API access tokens (AccessToken) for non-browser API usage.", + "listTokens": "List tokens", + "listTokensDesc": "Get all active access tokens for the current user.", + "createToken": "Create token", + "tokenName": "Token name", + "createTokenDesc": "Create a new access token. Returns the secret once.", + "isAdminDesc": "Whether to grant admin permissions (default false)", + "endpoint": "Endpoint", + "successResponseExample": "Success response example", + "deleteToken": "Delete token", + "deleteTokenDesc": "Revoke the specified access token.", + "rotateToken": "Rotate token", + "rotateTokenDesc": "Generate a new secret for the specified token. The old secret is immediately invalidated." + }, + "section4": { + "title": "System configuration management", + "publicConfig": "Get public configuration", + "publicConfigDesc": "Returns all system configuration items marked as public. No authentication required.", + "responseExample": "Response example", + "adminConfigs": "Admin configuration endpoints", + "adminConfigsDesc": "The following endpoints require admin permissions.", + "getConfigList": "Get configuration list", + "createConfig": "Create configuration", + "updateConfig": "Update configuration", + "deleteConfig": "Delete configuration" + } + }, + "howToUse": { + "title": "User guide", + "description": "Quick start guide for Wavelet Platform, covering authentication, tokens, configuration, tasks, and observability.", + "section1": { + "title": "System architecture overview", + "desc": "Wavelet Platform uses a Go + Next.js frontend-backend separated architecture.", + "architecture": "Tech stack", + "architectureDesc": "Go 1.25+ / Gin / GORM / PostgreSQL / Redis / Asynq", + "authSystem": "Authentication system", + "authSystemDesc": "Supports password login + OIDC third-party authentication", + "accessToken": "Access tokens", + "accessTokenDesc": "Personal Access Tokens for API calls", + "observability": "Observability", + "observabilityDesc": "OpenTelemetry + structured logging" + }, + "section2": { + "title": "Authentication & registration", + "desc": "The system supports multiple authentication methods, configurable via the admin panel.", + "passwordAuth": "Password authentication", + "selfRegister": "Self-registration", + "selfRegisterDesc": "Users can create accounts via the registration page.", + "bcryptDesc": "Passwords are stored with bcrypt encryption.", + "switchControl": "Toggle controls", + "switchControlDesc": "Admins can independently control:", + "oidc": "OIDC third-party login", + "oidcDesc": "Supports configuring any OIDC-compatible authentication source (e.g. Google, GitHub, enterprise SSO).", + "oidcStep1": "Add an authentication source in “System settings → Security”", + "oidcStep2": "Enter Discovery URL, Client ID, Client Secret", + "oidcStep3": "Once enabled, the login page will show the corresponding button" + }, + "section3": { + "title": "Access tokens (AccessToken)", + "desc": "Used for non-browser API calls.", + "generation": "Generation & management", + "oneTimeDisplay": "One-time display", + "oneTimeDisplayDesc": "The secret is only shown once when the token is created.", + "oneTimeDisplayHint": "Copy and save it immediately after creation.", + "secureHash": "Secure storage", + "secureHashDesc": "The system only stores a SHA-256 hash of the token. The original secret cannot be recovered.", + "headerUsage": "Usage", + "headerUsageDesc": "Include the token in the request header:", + "bearerHeader": "Bearer Token method", + "customHeader": "Custom header method" + }, + "section4": { + "title": "System configuration", + "desc": "Manage system configuration dynamically via the admin panel or API.", + "cacheAccel": "Cache acceleration", + "cacheAccelDesc": "Public configuration items support multi-level caching (memory → Redis → database), performing well in high-frequency read scenarios.", + "coreConfigs": "Core configuration items", + "siteName": "Site name", + "passwordLoginEnabled": "Password login toggle", + "registrationEnabled": "Registration toggle", + "maxApiKeys": "Max API keys" + }, + "section5": { + "title": "Async tasks", + "desc": "Distributed task queue system based on Asynq.", + "scheduler": "Scheduler", + "schedulerDesc": "Supports Cron expression configuration for scheduled tasks.", + "worker": "Worker", + "workerDesc": "Background worker processes consume and execute tasks." + }, + "section6": { + "title": "Observability", + "desc": "Built-in OpenTelemetry support.", + "tracing": "Distributed tracing", + "tracingDesc": "Supports exporting trace data to Jaeger, Zipkin, and other backends.", + "structuredLog": "Structured logging", + "structuredLogDesc": "High-performance structured logging based on Zap, with level-based output." + } + }, + "terms": { + "title": "Terms of service", + "description": "Please read the following terms of service carefully before using Wavelet Platform.", + "section1": { + "title": "General provisions", + "subject": "Agreement parties", + "subjectDesc": "This agreement is between you and the operator of Wavelet Platform regarding the use of platform services.", + "readCarefully": "Please read carefully", + "readCarefullyDesc": "Please read all contents of this agreement before using the service. If you disagree with any terms, please do not use the platform.", + "composition": "Agreement composition", + "compositionDesc": "This agreement includes the agreement text and all rules that the platform has published or may publish in the future. All rules are integral parts of this agreement and have the same legal effect as the agreement text." + }, + "section2": { + "title": "Service content", + "liability": "Scope of responsibility", + "liabilityDesc": "This platform provides only technical infrastructure and development tool services. Users bear all responsibilities arising from their use of the platform." + }, + "section3": { + "title": "User accounts", + "accountSystem": "Account system", + "accountSystemDesc": "After registering and obtaining an account on this platform, users should properly keep their account and password. Users are responsible for all actions under their account.", + "securityResponsibility": "Security responsibility", + "passwordSecurity": "Password security", + "passwordSecurityDesc": "Users should use sufficiently strong passwords and change them regularly. Losses caused by improper password keeping are the user's responsibility.", + "tokenSecurity": "Token security", + "tokenSecurityDesc": "Access tokens (AccessToken) are equivalent to account credentials and should be properly safeguarded.", + "tokenLeakLiability": "Any losses caused by token leakage are the user's responsibility." + }, + "section4": { + "title": "Usage rules", + "desc": "Users must comply with the following rules when using platform services:", + "redLine": "Prohibited actions", + "nationalSecurity": "Endangering national security", + "nationalSecurityDesc": "Must not use platform services to produce, copy, or publish information that endangers national security.", + "illegalInfo": "Illegal information", + "illegalInfoDesc": "Must not produce, copy, or publish information that violates national laws and regulations.", + "harmfulContent": "Harmful content", + "harmfulContentDesc": "Must not publish information containing harassment, insults, intimidation, or other harmful content.", + "ipInfringement": "Intellectual property infringement", + "ipInfringementDesc": "Must not infringe on others' intellectual property, trade secrets, or other legitimate rights.", + "otherIllegal": "Other illegal acts", + "otherIllegalDesc": "Must not use platform services to engage in other acts that violate laws and regulations.", + "enforcement": "Enforcement", + "enforcementDesc": "If users violate the above rules, the platform reserves the right to take the following actions:", + "enforcementAction": "Warning, suspension, or termination of service" + }, + "section5": { + "title": "Disclaimer", + "basicDisclaimer": "Basic disclaimer", + "basicDisclaimerDesc": "The platform will make every effort to ensure service security and stability, but is not responsible for the following situations:", + "techInterruption": "Technical interruption", + "techInterruptionDesc": "Service interruption or data loss caused by:", + "naturalDisaster": "Natural disasters and other force majeure", + "govAction": "Government actions or legal changes", + "telecomFailure": "Telecom operator failures", + "hacking": "Hacker attacks or computer viruses" + }, + "section6": { + "title": "Governing law & dispute resolution", + "applicableLaw": "Governing law", + "applicableLawDesc": "The conclusion, performance, and interpretation of this agreement are governed by:", + "lawName": "The laws of the People's Republic of China", + "lawScope": "Excluding conflict of laws rules", + "disputeResolution": "Dispute resolution", + "disputeResolutionDesc": "Any dispute arising from or related to this agreement shall be resolved through friendly negotiation. If negotiation fails, either party may file a lawsuit with a court of competent jurisdiction." + } + }, + "privacy": { + "title": "Privacy policy", + "description": "Wavelet Platform values user privacy. This policy explains how we collect, use, and protect your personal information.", + "descriptionRights": "Please read this policy carefully before using our services.", + "section1": { + "title": "Information collection", + "desc": "We collect the following types of information:", + "authInfo": "Authentication information", + "authInfoDesc": "Including username, email address, and encrypted password hash.", + "authInfoNote": "We do not store your plaintext password.", + "logInfo": "Access logs", + "logInfoDesc": "Including IP address, browser type, access time, and request path." + }, + "section2": { + "title": "Information usage", + "desc": "The information we collect is used for the following purposes:", + "storageSecurity": "Storage security", + "storageSecurityDesc": "All user data is encrypted and stored in secure server environments.", + "encryption": "Encryption measures", + "encryptionDesc": "Passwords use bcrypt one-way hash encryption. Tokens use SHA-256 hash storage.", + "accessControl": "Access control", + "accessControlDesc": "Strict permission management ensures only authorized personnel can access user data." + }, + "section3": { + "title": "Purpose of information use", + "desc": "The information we collect is used for the following purposes:", + "identity": "Identity verification", + "identityDesc": "Verify user identity and ensure account security.", + "business": "Business operations", + "businessDesc": "Provide, maintain, and improve our services.", + "riskControl": "Risk control", + "riskControlDesc": "Detect, prevent, and handle security risks and fraudulent activities.", + "prohibited": "Prohibited uses", + "prohibitedDesc": "We commit not to use user information for: selling to third parties, sending unsolicited commercial promotions." + }, + "section4": { + "title": "Information sharing & disclosure", + "sharingPrinciple": "Sharing principle", + "sharingPrincipleDesc": "We do not share personal information with third parties without user consent, except in the following situations:", + "sharingException1": "Required by laws, regulations, or government authorities through legal procedures", + "sharingException2": "Necessary to protect the platform's or the public's legitimate rights", + "transfer": "Information transfer", + "transferDesc": "In the event of a merger, acquisition, or similar transaction, we will require the new holder to continue to be bound by this policy." + }, + "section5": { + "title": "User rights", + "desc": "Users have the following rights regarding their personal information:", + "viewManage": "View and manage", + "viewManageDesc": "Users can view and modify their personal information at any time in their settings.", + "deleteAccount": "Account deletion", + "deleteAccountDesc": "Users can request deletion of their account and all associated personal data." + }, + "section6": { + "title": "Policy updates", + "desc": "We may update this privacy policy from time to time. After updates, we will post a notice on the platform.", + "notification": "Significant changes will be notified via in-app notification or email." + } + } + }, + "files": { + "uploadSuccess": "File uploaded successfully", + "uploadFailed": "Upload failed", + "deleteSuccess": "File deleted successfully", + "deleteFailed": "Delete failed", + "searchPlaceholder": "Search file name...", + "private": "Private", + "public": "Public", + "uploading": "Uploading...", + "uploadFile": "Upload file", + "noFiles": "No files uploaded yet", + "noMatchingFiles": "No matching files found", + "uploadFirstFile": "Upload your first file. Supports images, videos, documents, and archives.", + "selectFileUpload": "Select file to upload", + "downloadFile": "Download file", + "deleteFile": "Delete file", + "prevPage": "Previous", + "nextPage": "Next", + "confirmDeleteFile": "Confirm file deletion", + "confirmDeleteFileDesc": "Delete file “{name}”? This file cannot be recovered after deletion.", + "cancel": "Cancel", + "confirmDelete": "Confirm delete", + "myFiles": "My files" + }, + "general": { + "manage": { + "saveSuccess": "Saved", + "saveFailed": "Save failed", + "unknownError": "Unknown error", + "deleteSuccess": "Deleted", + "deleteFailed": "Delete failed", + "loading": "Loading", + "noData": "No data", + "configList": "Configuration list", + "confirmDelete": "Confirm delete?", + "confirmDeleteDesc": "This cannot be undone. This will permanently delete the configuration.", + "cancel": "Cancel", + "confirmDeleteAction": "Confirm delete", + "configInfo": "Configuration info", + "selectConfigToView": "Select a configuration to view details", + "updating": "Updating", + "update": "Update", + "actions": "Actions" + }, + "passwordDialog": { + "enterPassword": "Enter password", + "enterPasswordDesc": "For your account security, please enter the 6-digit verification password", + "cancel": "Cancel", + "verifying": "Verifying...", + "confirm": "Confirm" + } + }, + "home": { + "banner": { + "description": "A general-purpose secondary development platform with built-in user management, authentication, file storage, task scheduling, push notifications, and observability to help developers quickly start new projects.", + "getStarted": "Get started", + "hideHint": "Dismiss" + }, + "navigation": { + "title": "Navigation" + }, + "quickLinks": { + "profile": { + "title": "Profile", + "description": "View and edit your account information" + }, + "apiDocs": { + "title": "API docs", + "description": "Complete API reference" + }, + "userDocs": { + "title": "User guide", + "description": "Quick start guide" + } + }, + "adminLinks": { + "settings": { + "title": "System settings", + "description": "Configure security, operations, and appearance" + }, + "systemConfig": { + "title": "System config", + "description": "Manage key-value system configuration items" + }, + "userManagement": { + "title": "User management", + "description": "View and manage all registered users" + }, + "tasks": { + "title": "Tasks", + "description": "Async task queue and scheduled task scheduling" + }, + "logs": { + "title": "System logs", + "description": "User access logs and application runtime logs" + } + }, + "jumpNow": "Jump now", + "enterNow": "Enter now", + "adminConsole": "Admin console", + "developerSection": { + "heading": "Developer friendly", + "description": "Built-in complete RESTful API docs, Swagger UI, and TypeScript SDK examples for quick integration.", + "features": { + "restful": "Complete RESTful API", + "swagger": "Online Swagger docs", + "typescript": "TypeScript SDK examples", + "examples": "Rich usage examples" + }, + "apiDocs": "View API docs", + "codeComment": "// Quick start — API call example", + "codeQuickStart": "Quick start" + }, + "hero": { + "subtitle": "Built-in user authentication, file storage, task scheduling,", + "subtitleLine2": "push notifications, and observability to help you quickly launch your next project.", + "getStarted": "Get started", + "learnMore": "Learn more", + "features": { + "readyToUse": "Ready to use", + "highlyExtensible": "Highly extensible", + "industrialGrade": "Industrial grade" + } + }, + "footer": { + "description": "A general-purpose platform scaffold for secondary development", + "product": "Product", + "dashboard": "Dashboard", + "personalSettings": "Personal settings", + "development": "Development", + "quickStart": "Quick start", + "apiDocs": "API docs", + "sourceCode": "Source code", + "community": "Community", + "discussions": "Discussions", + "privacyPolicy": "Privacy policy", + "termsOfService": "Terms of service" + } + }, + "metadata": { + "title": "OpenFlare", + "description": "OpenFlare edge node and reverse proxy control plane" + }, + "notFound": { + "description": "Sorry, the page you're looking for seems lost in the digital nebula. Please check the path or return to safety.", + "previousPage": "Previous page", + "home": "Home" + }, + "providers": { + "titleUpdater": { + "login": "Login", + "register": "Register", + "admin": "Admin", + "dashboard": "Dashboard" + } + } +} diff --git a/frontend/messages/fragments/core.zh-CN.json b/frontend/messages/fragments/core.zh-CN.json new file mode 100644 index 00000000..9a4e7bae --- /dev/null +++ b/frontend/messages/fragments/core.zh-CN.json @@ -0,0 +1,479 @@ +{ + "common": { + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "delete": "删除", + "loading": "加载中...", + "retry": "重试", + "optional": "(可选)", + "settings": "设置", + "search": "搜索", + "unknownError": "发生错误,请重试", + "loadFailed": "加载失败", + "previousPage": "上一页", + "nextPage": "下一页" + }, + "contexts": { + "adminUsers": { + "userStatusChanged": "已{action}用户 {username}", + "enabled": "启用", + "disabled": "禁用", + "updateStatusFailed": "更新状态失败", + "userCreated": "已成功创建用户 {username}", + "createUserFailed": "创建用户失败", + "userDeleted": "已删除用户 {username}", + "deleteUserFailed": "删除用户失败", + "updateUserSuccess": "更新用户信息成功", + "updateUserFailed": "更新用户信息失败" + }, + "admin": { + "loadConfigFailed": "加载系统配置失败", + "updateConfigFailed": "更新系统配置失败" + }, + "user": { + "getUserInfoFailed": "获取用户信息失败", + "logoutFailed": "登出失败" + } + }, + "docs": { + "backToHome": "返回首页", + "sectionCount": "共 {count} 个章节", + "lastUpdated": "最后更新", + "copied": "已复制", + "copyMarkdown": "复制 Markdown", + "toc": "目录", + "haveQuestions": "还有疑问?", + "contactSupport": "联系客服支持", + "api": { + "title": "接口文档", + "description": "Wavelet Platform 后端 API 完整参考,涵盖认证、用户、令牌与配置管理。", + "field": "字段", + "type": "类型", + "descriptionLabel": "说明", + "endpoint": "端点", + "desc": "描述", + "param": "参数", + "required": "必需", + "yes": "是", + "no": "否", + "username": "用户名", + "password": "密码", + "optional": "可选", + "default": "默认", + "section1": { + "title": "通用约定", + "desc": "所有 API 响应遵循统一信封格式。", + "responseFormat": "响应格式", + "responseFormatDesc": "所有接口均返回以下 JSON 结构:", + "errorMsgDesc": "成功时为空字符串,失败时为可读错误信息", + "dataDesc": "业务数据,结构因接口而异", + "successExample": "成功响应示例", + "failExample": "失败响应示例", + "failExampleErrorMsg": "错误信息", + "authentication": "认证方式", + "authenticationDesc": "系统支持两种认证方式,所有受保护的接口均需携带其一:", + "sessionCredential": "Session 凭证", + "sessionCredentialDesc": "登录后由服务端 Set-Cookie 返回,浏览器自动携带。", + "accessTokenCredential": "AccessToken 凭证", + "accessTokenCredentialDesc": "通过 Header 传递,适用于 API 调用与第三方集成:", + "supportedHeaders": "支持的 Header" + }, + "section2": { + "title": "认证接口", + "register": "注册", + "registerDesc": "创建新用户账号并自动登录。", + "usernameDesc": "用户名,唯一,至少 3 位", + "passwordDesc": "密码,至少 8 位", + "nicknameDesc": "昵称(可选)", + "passwordLogin": "密码登录", + "passwordLoginDesc": "使用邮箱/用户名 + 密码登录,成功后 Set-Cookie。", + "logout": "登出", + "logoutDesc": "销毁当前会话。", + "getProfile": "获取当前用户信息", + "getProfileDesc": "返回当前已登录用户的完整档案。" + }, + "section3": { + "title": "访问令牌管理", + "desc": "管理个人 API 访问令牌(AccessToken),用于无浏览器场景的 API 调用。", + "listTokens": "列出令牌", + "listTokensDesc": "获取当前用户的所有活跃访问令牌。", + "createToken": "创建令牌", + "tokenName": "令牌名称", + "createTokenDesc": "创建新的访问令牌,返回一次性密钥。", + "isAdminDesc": "是否授予管理员权限(默认 false)", + "endpoint": "端点", + "successResponseExample": "成功响应示例", + "deleteToken": "删除令牌", + "deleteTokenDesc": "撤销指定的访问令牌。", + "rotateToken": "轮换令牌", + "rotateTokenDesc": "为指定令牌生成新密钥,旧密钥立即失效。" + }, + "section4": { + "title": "系统配置管理", + "publicConfig": "获取公开配置", + "publicConfigDesc": "返回所有标记为公开的系统配置项,无需认证。", + "responseExample": "响应示例", + "adminConfigs": "管理员配置接口", + "adminConfigsDesc": "以下接口需要管理员权限。", + "getConfigList": "获取配置列表", + "createConfig": "创建配置", + "updateConfig": "更新配置", + "deleteConfig": "删除配置" + } + }, + "howToUse": { + "title": "使用文档", + "description": "Wavelet Platform 快速上手指南,涵盖认证、令牌、配置、任务与可观测性。", + "section1": { + "title": "系统架构概览", + "desc": "Wavelet Platform 采用 Go + Next.js 前后端分离架构。", + "architecture": "技术栈", + "architectureDesc": "Go 1.25+ / Gin / GORM / PostgreSQL / Redis / Asynq", + "authSystem": "认证系统", + "authSystemDesc": "支持密码登录 + OIDC 第三方认证", + "accessToken": "访问令牌", + "accessTokenDesc": "用于 API 调用的 Personal Access Token", + "observability": "可观测性", + "observabilityDesc": "OpenTelemetry + 结构化日志" + }, + "section2": { + "title": "认证与注册", + "desc": "系统支持多种认证方式,可通过管理后台灵活配置。", + "passwordAuth": "密码认证", + "selfRegister": "自主注册", + "selfRegisterDesc": "用户可通过注册页面创建账号。", + "bcryptDesc": "密码使用 bcrypt 加密存储。", + "switchControl": "开关控制", + "switchControlDesc": "管理员可在后台独立控制:", + "oidc": "OIDC 第三方登录", + "oidcDesc": "支持配置任意 OIDC 兼容的认证源(如 Google、GitHub、企业 SSO)。", + "oidcStep1": "在「系统设置 → 安全设置」中新增认证源", + "oidcStep2": "填写 Discovery URL、Client ID、Client Secret", + "oidcStep3": "启用后,登录页将自动显示对应入口" + }, + "section3": { + "title": "访问令牌 (AccessToken)", + "desc": "用于无浏览器场景的 API 调用。", + "generation": "生成与管理", + "oneTimeDisplay": "一次性显示", + "oneTimeDisplayDesc": "创建令牌后,密钥仅显示一次。", + "oneTimeDisplayHint": "请务必在创建后立即复制保存。", + "secureHash": "安全存储", + "secureHashDesc": "系统仅存储令牌的 SHA-256 哈希,原始密钥不可恢复。", + "headerUsage": "使用方式", + "headerUsageDesc": "在请求 Header 中携带令牌:", + "bearerHeader": "Bearer Token 方式", + "customHeader": "自定义 Header 方式" + }, + "section4": { + "title": "系统配置", + "desc": "通过管理后台或 API 动态管理系统配置。", + "cacheAccel": "缓存加速", + "cacheAccelDesc": "公开配置项支持多级缓存(内存 → Redis → 数据库),高频读取场景下性能优异。", + "coreConfigs": "核心配置项", + "siteName": "站点名称", + "passwordLoginEnabled": "密码登录开关", + "registrationEnabled": "注册开关", + "maxApiKeys": "最大 API Key 数量" + }, + "section5": { + "title": "异步任务", + "desc": "基于 Asynq 的分布式任务队列系统。", + "scheduler": "定时任务 (Scheduler)", + "schedulerDesc": "支持 Cron 表达式配置定时任务。", + "worker": "Worker", + "workerDesc": "后台 Worker 进程负责消费和执行任务。" + }, + "section6": { + "title": "可观测性", + "desc": "内置 OpenTelemetry 支持。", + "tracing": "链路追踪", + "tracingDesc": "支持将 Trace 数据导出到 Jaeger、Zipkin 等后端。", + "structuredLog": "结构化日志", + "structuredLogDesc": "基于 Zap 的高性能结构化日志,支持按级别输出。" + } + }, + "terms": { + "title": "服务协议", + "description": "使用 Wavelet Platform 前,请仔细阅读以下服务条款。", + "section1": { + "title": "总则", + "subject": "协议主体", + "subjectDesc": "本协议是您与 Wavelet Platform 运营方之间关于使用平台服务所订立的协议。", + "readCarefully": "请仔细阅读", + "readCarefullyDesc": "请您在使用服务前仔细阅读本协议全部内容。如您不同意本协议的任何条款,请勿使用本平台服务。", + "composition": "协议构成", + "compositionDesc": "本协议正文包括协议正文、平台已经发布或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。" + }, + "section2": { + "title": "服务内容", + "liability": "责任范围", + "liabilityDesc": "本平台仅提供技术基础设施和开发工具服务。用户应自行承担使用平台过程中产生的所有责任。" + }, + "section3": { + "title": "用户账号", + "accountSystem": "账号体系", + "accountSystemDesc": "用户在本平台注册并获得账号后,应妥善保管其账号和密码。用户应对其账号项下的所有行为负责。", + "securityResponsibility": "安全责任", + "passwordSecurity": "密码安全", + "passwordSecurityDesc": "用户应使用安全强度足够的密码,并定期更换。因用户密码保管不当导致的损失,由用户自行承担。", + "tokenSecurity": "令牌安全", + "tokenSecurityDesc": "访问令牌(AccessToken)等同于账号凭证,用户应妥善保管。", + "tokenLeakLiability": "因令牌泄露导致的任何损失,由用户自行承担。" + }, + "section4": { + "title": "使用规范", + "desc": "用户在使用本平台服务时,必须遵守以下规范:", + "redLine": "禁止行为红线", + "nationalSecurity": "危害国家安全", + "nationalSecurityDesc": "不得利用平台服务制作、复制、发布含有危害国家安全的信息。", + "illegalInfo": "违法信息", + "illegalInfoDesc": "不得制作、复制、发布违反国家法律法规的信息。", + "harmfulContent": "有害内容", + "harmfulContentDesc": "不得发布含有骚扰、侮辱、恐吓或其他有害内容的信息。", + "ipInfringement": "知识产权侵权", + "ipInfringementDesc": "不得侵犯他人知识产权、商业秘密等合法权益。", + "otherIllegal": "其他违法行为", + "otherIllegalDesc": "不得利用平台服务从事其他违反法律法规的行为。", + "enforcement": "违规处理", + "enforcementDesc": "如用户违反上述规范,平台有权采取以下措施:", + "enforcementAction": "警告、暂停或终止服务" + }, + "section5": { + "title": "免责声明", + "basicDisclaimer": "基本免责", + "basicDisclaimerDesc": "平台将尽最大努力确保服务的安全性和稳定性,但不对以下情形承担责任:", + "techInterruption": "技术中断", + "techInterruptionDesc": "因以下原因导致的服务中断或数据丢失:", + "naturalDisaster": "自然灾害等不可抗力", + "govAction": "政府行为或法律法规变更", + "telecomFailure": "电信运营商故障", + "hacking": "黑客攻击或计算机病毒" + }, + "section6": { + "title": "法律适用与争议解决", + "applicableLaw": "适用法律", + "applicableLawDesc": "本协议的签订、履行和解释均适用:", + "lawName": "中华人民共和国法律", + "lawScope": "不含冲突法规则", + "disputeResolution": "争议解决", + "disputeResolutionDesc": "因本协议引起的或与本协议有关的任何争议,双方应友好协商解决。协商不成的,任何一方均有权向有管辖权的人民法院提起诉讼。" + } + }, + "privacy": { + "title": "隐私政策", + "description": "Wavelet Platform 重视用户隐私保护。本政策说明我们如何收集、使用和保护您的个人信息。", + "descriptionRights": "请在使用我们的服务前仔细阅读本政策。", + "section1": { + "title": "信息收集", + "desc": "我们收集以下类型的信息:", + "authInfo": "认证信息", + "authInfoDesc": "包括用户名、邮箱地址、加密后的密码哈希。", + "authInfoNote": "我们不会存储您的明文密码。", + "logInfo": "访问日志", + "logInfoDesc": "包括 IP 地址、浏览器类型、访问时间、请求路径。" + }, + "section2": { + "title": "信息使用", + "desc": "我们收集的信息用于以下目的:", + "storageSecurity": "存储安全", + "storageSecurityDesc": "所有用户数据均加密存储于安全的服务器环境中。", + "encryption": "加密措施", + "encryptionDesc": "密码使用 bcrypt 单向哈希加密,令牌使用 SHA-256 哈希存储。", + "accessControl": "访问控制", + "accessControlDesc": "严格的权限管理机制确保只有授权人员才能访问用户数据。" + }, + "section3": { + "title": "信息使用目的", + "desc": "我们收集的信息将用于以下目的:", + "identity": "身份验证", + "identityDesc": "验证用户身份,确保账号安全。", + "business": "业务运营", + "businessDesc": "提供、维护和改进我们的服务。", + "riskControl": "风险控制", + "riskControlDesc": "检测、预防和处理安全风险及欺诈行为。", + "prohibited": "禁止用途", + "prohibitedDesc": "我们承诺不会将用户信息用于以下用途:出售给第三方、发送未经许可的商业推广。" + }, + "section4": { + "title": "信息共享与披露", + "sharingPrinciple": "共享原则", + "sharingPrincipleDesc": "未经用户同意,我们不会向第三方共享用户个人信息,但以下情况除外:", + "sharingException1": "法律法规要求、政府机关依法定程序调取", + "sharingException2": "为保护平台或公众的合法权益所必需", + "transfer": "信息转让", + "transferDesc": "如平台发生合并、收购等情形,我们会要求新的持有者继续受本政策约束。" + }, + "section5": { + "title": "用户权利", + "desc": "用户对其个人信息享有以下权利:", + "viewManage": "查看与管理", + "viewManageDesc": "用户可随时在个人设置中查看和修改其个人信息。", + "deleteAccount": "账号删除", + "deleteAccountDesc": "用户可申请删除其账号及关联的所有个人数据。" + }, + "section6": { + "title": "政策更新", + "desc": "我们可能会不时更新本隐私政策。政策更新后,我们会在平台上发布通知。", + "notification": "重大变更将通过站内通知或邮件方式告知用户。" + } + } + }, + "files": { + "uploadSuccess": "文件上传成功", + "uploadFailed": "上传失败", + "deleteSuccess": "文件已成功删除", + "deleteFailed": "删除失败", + "searchPlaceholder": "搜索文件名...", + "private": "私有", + "public": "公开", + "uploading": "正在上传...", + "uploadFile": "上传文件", + "noFiles": "还没有上传文件", + "noMatchingFiles": "未搜索到匹配的文件", + "uploadFirstFile": "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。", + "selectFileUpload": "选择文件上传", + "downloadFile": "下载文件", + "deleteFile": "删除文件", + "prevPage": "上一页", + "nextPage": "下一页", + "confirmDeleteFile": "确认删除文件", + "confirmDeleteFileDesc": "确定要删除文件「{name}」吗?删除后此文件将无法恢复。", + "cancel": "取消", + "confirmDelete": "确认删除", + "myFiles": "我的文件" + }, + "general": { + "manage": { + "saveSuccess": "保存成功", + "saveFailed": "保存失败", + "unknownError": "未知错误", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "loading": "加载中", + "noData": "暂无数据", + "configList": "配置列表", + "confirmDelete": "确认删除?", + "confirmDeleteDesc": "此操作无法撤销。这将永久删除该配置。", + "cancel": "取消", + "confirmDeleteAction": "确认删除", + "configInfo": "配置信息", + "selectConfigToView": "请选择配置查看详情", + "updating": "更新中", + "update": "更新", + "actions": "操作" + }, + "passwordDialog": { + "enterPassword": "请输入密码", + "enterPasswordDesc": "为了您的账户安全,请输入6位认证密码进行验证", + "cancel": "取消", + "verifying": "验证中...", + "confirm": "确认" + } + }, + "home": { + "banner": { + "description": "这是一个通用的二次开发基础平台,内置用户管理、认证鉴权、文件存储、任务调度、通知推送和可观测性等常用功能模块,帮助开发者快速启动新项目。", + "getStarted": "快速开始", + "hideHint": "收起提示" + }, + "navigation": { + "title": "导航" + }, + "quickLinks": { + "profile": { + "title": "个人资料", + "description": "查看和编辑您的账户信息" + }, + "apiDocs": { + "title": "接口文档", + "description": "API 接口完整参考" + }, + "userDocs": { + "title": "使用文档", + "description": "快速上手指南" + } + }, + "adminLinks": { + "settings": { + "title": "系统设置", + "description": "配置系统安全、业务与外观参数" + }, + "systemConfig": { + "title": "系统配置", + "description": "管理 Key-Value 系统配置项" + }, + "userManagement": { + "title": "用户管理", + "description": "查看和管理所有注册用户" + }, + "tasks": { + "title": "任务管理", + "description": "异步任务队列与定时任务调度" + }, + "logs": { + "title": "系统日志", + "description": "用户访问日志与应用运行日志" + } + }, + "jumpNow": "立即跳转", + "enterNow": "立即进入", + "adminConsole": "管理后台", + "developerSection": { + "heading": "开发者友好", + "description": "内置完整的 RESTful API 文档、Swagger UI 与 TypeScript SDK 示例,帮助您快速集成。", + "features": { + "restful": "完整 RESTful API", + "swagger": "在线 Swagger 文档", + "typescript": "TypeScript SDK 示例", + "examples": "丰富的使用示例" + }, + "apiDocs": "查看接口文档", + "codeComment": "// 快速开始 — API 调用示例", + "codeQuickStart": "快速开始" + }, + "hero": { + "subtitle": "内置用户认证、文件存储、任务调度", + "subtitleLine2": "通知推送与可观测性,帮助您快速启动下一个项目。", + "getStarted": "快速开始", + "learnMore": "了解更多", + "features": { + "readyToUse": "开箱即用", + "highlyExtensible": "高度可扩展", + "industrialGrade": "工业级品质" + } + }, + "footer": { + "description": "通用二次开发标准范式平台", + "product": "产品", + "dashboard": "控制台", + "personalSettings": "个人设置", + "development": "开发", + "quickStart": "快速开始", + "apiDocs": "接口文档", + "sourceCode": "源代码", + "community": "社区", + "discussions": "Discussions", + "privacyPolicy": "隐私政策", + "termsOfService": "服务条款" + } + }, + "metadata": { + "title": "OpenFlare", + "description": "OpenFlare 边缘节点与反向代理管理平台" + }, + "notFound": { + "description": "抱歉,您访问的页面似已迷失在数字星云中。请检查路径或返回安全地带。", + "previousPage": "上一页", + "home": "首页" + }, + "providers": { + "titleUpdater": { + "login": "登录", + "register": "注册", + "admin": "后台管理", + "dashboard": "控制台" + } + } +} diff --git a/frontend/messages/fragments/layout.en.json b/frontend/messages/fragments/layout.en.json new file mode 100644 index 00000000..c4a183f5 --- /dev/null +++ b/frontend/messages/fragments/layout.en.json @@ -0,0 +1,141 @@ +{ + "layout": { + "nav": { + "home": "Home", + "myFiles": "My Files", + "users": "Users", + "tasks": "Tasks", + "storage": "Storage", + "database": "Database", + "push": "Notifications", + "messageGateway": "Messaging", + "logs": "System Logs", + "system": "System Config", + "adminSettings": "System Settings", + "demo": "Demo", + "apiDocs": "API Docs", + "usageDocs": "User Guide", + "dashboard": "Dashboard", + "nodes": "Nodes", + "proxyRoutes": "Proxy Routes", + "websites": "Domains", + "certificates": "TLS Certificates", + "dnsAccounts": "DNS Accounts", + "cloudflare": "Cloudflare", + "origins": "Origins", + "responses": "Response Pages", + "waf": "WAF", + "ipGroups": "IP Groups", + "rateLimits": "Rate Limits", + "pages": "Pages", + "configVersions": "Releases", + "accessLogs": "Access Logs", + "performance": "Performance", + "applyLogs": "Apply Logs" + }, + "groups": { + "admin": "Admin", + "docs": "Docs", + "security": "Security", + "websites": "Websites" + }, + "user": { + "adminRole": "Administrator", + "userRole": "User", + "notLoggedIn": "Not signed in", + "syncing": "Syncing account", + "profile": "My Profile", + "settings": "Settings", + "help": "Help", + "logout": "Sign out", + "unknownUser": "Unknown user" + }, + "logoutDialog": { + "title": "Sign out", + "description": "Are you sure you want to sign out?", + "loggingOut": "Signing out…", + "confirm": "Sign out", + "inProgress": "Signing out…", + "failed": "Sign-out failed", + "failedDescription": "An error occurred while signing out. Please try again." + }, + "header": { + "notifications": "Notifications", + "settings": "Settings", + "search": "Search", + "toggleFullWidth": "Toggle full width", + "toggleTheme": "Toggle theme", + "changePasswordBanner": "For your account security, please change your password now.", + "changePasswordAction": "Change password", + "bannerTitle": "Announcement", + "bannerLink": "Learn more", + "dismiss": "Dismiss", + "guest": "Guest" + }, + "search": { + "categoryPage": "Page", + "categoryFeature": "Feature", + "categorySetting": "Setting", + "categoryAdmin": "Admin", + "tipPrefix": "Tips:", + "tipSlash": "You can also press / to open this dialog", + "tipArrow": "to navigate items", + "tipNewTab": "Open in new tab", + "tipDidYouKnow": "Did you know: search is still being improved", + "searchPlaceholder": "Search pages and features...", + "noResults": "No results found. Try a different term?", + "openInNewTab": "Open in new tab", + "open": "Open", + "closeSearch": "Close search", + "items": { + "globalSettings": "Settings", + "appearance": "Appearance", + "homeDesc": "Return to the console dashboard", + "usageDocsDesc": "View getting-started guides and integration examples", + "nodesDesc": "Manage edge nodes, relays, and intranet tunnels", + "proxyRoutesDesc": "Configure reverse proxies, match rules, WAF policies, and cache settings", + "websitesDesc": "Manage hosted domains, certificate bindings, and listeners", + "certificatesDesc": "Issue and manage SSL/TLS certificates with auto-renewal", + "dnsAccountsDesc": "Configure DNS provider credentials for certificate issuance and records", + "originsDesc": "Manage reverse-proxy backends and load-balancing groups", + "responsesDesc": "Configure origin error pages and offline fallback contact pages", + "wafDesc": "Configure Web Application Firewall rules to block malicious requests", + "ipGroupsDesc": "Define IP lists for allow/deny control in WAF or routes", + "rateLimitsDesc": "Configure default concurrency and bandwidth limits for edge sites", + "pagesDesc": "Upload or deploy static sites with global CDN hosting", + "configVersionsDesc": "View, compare, publish, and roll back configuration versions", + "accessLogsDesc": "Search full-site access logs and network analytics", + "applyLogsDesc": "View history of node config delivery, sync, and activation", + "performanceDesc": "Tune connections, proxy timeouts, and core performance settings", + "settingsDesc": "Configure personal application preferences", + "profileDesc": "Edit nickname, avatar, and profile attributes", + "appearanceDesc": "Configure the display theme (light/dark)", + "adminSettingsDesc": "Manage login, registration, and auth sources (admin only)", + "systemDesc": "Change core platform runtime configuration (admin only)", + "usersDesc": "Manage registered users and their status (admin only)", + "tasksDesc": "Dispatch and inspect background scheduled tasks (admin only)", + "storageDesc": "Browse, search, and clean files in object storage (admin only)", + "databaseDesc": "Monitor table size, browse physical tables, and run interactive SQL (admin only)", + "pushDesc": "Configure email, Lark, and Telegram notification channels (admin only)", + "logsDesc": "View system logs and background task execution logs (admin only)" + } + }, + "error": { + "loadFailed": "Load failed", + "retry": "Retry", + "unknownError": "An unknown error occurred. Please try again later.", + "viewDetails": "View error details", + "unknownErrorShort": "Unknown error" + }, + "loading": { + "loading": "Loading", + "loadingDesc": "Fetching data..." + }, + "empty": { + "noData": "No data", + "noContent": "No records found" + }, + "toggleSidebar": "Toggle sidebar", + "mainNavigation": "Main navigation" + } +} diff --git a/frontend/messages/fragments/layout.zh-CN.json b/frontend/messages/fragments/layout.zh-CN.json new file mode 100644 index 00000000..4c15d640 --- /dev/null +++ b/frontend/messages/fragments/layout.zh-CN.json @@ -0,0 +1,141 @@ +{ + "layout": { + "nav": { + "home": "首页", + "myFiles": "我的文件", + "users": "用户管理", + "tasks": "任务管理", + "storage": "存储管理", + "database": "数据管理", + "push": "通知推送", + "messageGateway": "消息通道", + "logs": "系统日志", + "system": "系统配置", + "adminSettings": "系统设置", + "demo": "规范示例", + "apiDocs": "接口文档", + "usageDocs": "使用文档", + "dashboard": "数据看板", + "nodes": "节点管理", + "proxyRoutes": "规则管理", + "websites": "域名列表", + "certificates": "TLS证书", + "dnsAccounts": "DNS账号", + "cloudflare": "Cloudflare", + "origins": "源站地址", + "responses": "响应页面", + "waf": "WAF", + "ipGroups": "IP 组", + "rateLimits": "限流", + "pages": "Pages", + "configVersions": "版本发布", + "accessLogs": "访问日志", + "performance": "性能调优", + "applyLogs": "应用日志" + }, + "groups": { + "admin": "管理", + "docs": "文档库", + "security": "安全性", + "websites": "网站管理" + }, + "user": { + "adminRole": "系统管理员", + "userRole": "普通用户", + "notLoggedIn": "未登录", + "syncing": "正在同步账户", + "profile": "我的资料", + "settings": "设置", + "help": "使用帮助", + "logout": "退出登录", + "unknownUser": "未知用户" + }, + "logoutDialog": { + "title": "确认登出", + "description": "您确定要登出当前账户吗?", + "loggingOut": "正在登出,请稍候...", + "confirm": "确认登出", + "inProgress": "登出中...", + "failed": "登出失败", + "failedDescription": "登出时发生错误,请重试" + }, + "header": { + "notifications": "通知", + "settings": "设置", + "search": "搜索", + "toggleFullWidth": "切换全宽", + "toggleTheme": "主题切换", + "changePasswordBanner": "为了您的账号安全,请立即修改密码!", + "changePasswordAction": "去修改密码", + "bannerTitle": "最新通知", + "bannerLink": "查看详情", + "dismiss": "关闭", + "guest": "访客" + }, + "search": { + "categoryPage": "页面", + "categoryFeature": "功能", + "categorySetting": "设置", + "categoryAdmin": "管理", + "tipPrefix": "Tips:", + "tipSlash": "还可以使用 / 来打开此界面", + "tipArrow": "来切换选中项", + "tipNewTab": "在新标签页打开", + "tipDidYouKnow": "你知道吗:搜索功能还在持续升级中", + "searchPlaceholder": "搜索页面和功能...", + "noResults": "没有找到相关内容,换个词试试?", + "openInNewTab": "在新标签页打开", + "open": "打开", + "closeSearch": "关闭搜索界面", + "items": { + "globalSettings": "全局设置", + "appearance": "外观设置", + "homeDesc": "返回控制台总览", + "usageDocsDesc": "查看新手教程和集成示例", + "nodesDesc": "管理边缘节点、中继节点与内网穿透通道", + "proxyRoutesDesc": "配置反向代理、路由匹配规则、WAF 策略与缓存设置", + "websitesDesc": "管理托管域名及证书绑定与监听配置", + "certificatesDesc": "申请与管理 SSL/TLS 证书,支持自动续期", + "dnsAccountsDesc": "配置 DNS 服务商 API 凭证以自动申请证书及管理解析", + "originsDesc": "管理反向代理的目标后端服务器与负载均衡组", + "responsesDesc": "配置源站错误页与离线兜底联系页", + "wafDesc": "配置 Web 应用防火墙规则,阻断恶意请求", + "ipGroupsDesc": "定义 IP 地址列表以在 WAF 或路由中实现黑白名单控制", + "rateLimitsDesc": "配置边缘站点默认并发与带宽限流策略", + "pagesDesc": "上传或部署静态网页,提供全球 CDN 加速托管", + "configVersionsDesc": "查看、对比、发布与回滚系统配置版本", + "accessLogsDesc": "查看并检索全量网站访问请求日志与网络分析数据", + "applyLogsDesc": "查看节点配置下发、同步与生效的历史记录", + "performanceDesc": "调优网络连接、代理超时与核心系统性能参数", + "settingsDesc": "配置应用个人偏好选项", + "profileDesc": "编辑昵称、头像和个人属性", + "appearanceDesc": "配置系统显示主题(亮色/暗色)", + "adminSettingsDesc": "管理系统登录注册与认证源配置 (管理员专属)", + "systemDesc": "动态修改平台核心运行时配置 (管理员专属)", + "usersDesc": "管理平台注册用户的活跃状态 (管理员专属)", + "tasksDesc": "下发与排查后台异步定时任务 (管理员专属)", + "storageDesc": "查看、检索与清理上传到对象存储中的文件 (管理员专属)", + "databaseDesc": "监控数据库表大小、分页浏览物理表内容并支持交互式 SQL (管理员专属)", + "pushDesc": "配置与下发邮件、Lark 和 Telegram 渠道通知推送 (管理员专属)", + "logsDesc": "查看系统日志与后台异步任务执行日志 (管理员专属)" + } + }, + "error": { + "loadFailed": "加载失败", + "retry": "重试", + "unknownError": "发生未知错误,请稍后重试", + "viewDetails": "查看详细错误信息", + "unknownErrorShort": "发生未知错误" + }, + "loading": { + "loading": "加载中", + "loadingDesc": "正在获取活动数据..." + }, + "empty": { + "noData": "暂无数据", + "noContent": "当前没有任何记录" + }, + "toggleSidebar": "切换侧边栏", + "mainNavigation": "主导航" + } +} diff --git a/frontend/messages/fragments/ops.en.json b/frontend/messages/fragments/ops.en.json new file mode 100644 index 00000000..a64550e4 --- /dev/null +++ b/frontend/messages/fragments/ops.en.json @@ -0,0 +1,673 @@ +{ + "dashboard": { + "title": "Overview", + "generatedAt": "Generated at {time}", + "refresh": "Refresh", + "loadingTitle": "Loading overview", + "loadingDesc": "Aggregating node health, traffic, and capacity metrics...", + "loadFailed": "Failed to load overview: {error}", + "emptyTitle": "No overview data", + "emptyDesc": "The system is running, but there is no aggregated overview to show yet.", + "requestFailed": "Request failed. Please try again later.", + "stats": { + "requests24h": "24h requests", + "requestsMeta": "24h unique visitors {visitors} · errors {errors} · estimated QPS {qps}", + "clusterCapacity": "Cluster capacity", + "avgMemoryHighLoad": "Avg memory {memory} · high-load nodes CPU {cpu} / memory {memoryNodes} / storage {storage}", + "nodeOverview": "Node overview", + "onlineOf": "/ {total} online", + "pending": "Pending {count}", + "unhealthyOffline": "Unhealthy {unhealthy} · offline {offline}" + }, + "world": { + "title": "Global stage", + "description": "Summarizes node distribution, source-country heat, and recent runtime status.", + "legendSource": "Source", + "legendHealthy": "Healthy", + "legendPressure": "Under load", + "legendUnhealthy": "Unhealthy", + "modeVisitorSource": "Visitor sources", + "modeNodeCoords": "Node coordinates", + "modeCoverage": "Coverage signal", + "onlineCoverage": "Online coverage", + "onlineHint": "{online}/{total} online", + "runHealth": "Runtime health", + "unhealthyCount": "{count} unhealthy", + "requests24h": "24h requests", + "qpsReported": "QPS {qps} · {count} nodes reporting", + "nodeCoords": "Node coordinates", + "sourceCountries": "{count} source countries", + "fallbackPins": "Preset pins are used when coordinates are missing", + "avgCpu": "Avg CPU", + "highCount": "{count} high", + "avgMemory": "Avg memory", + "highStorage": "High storage", + "offlinePending": "{offline} offline · {pending} pending", + "mapPreparing": "Preparing map", + "mapPreparingDesc": "Loads when it enters the viewport", + "noNodesOverlay": "No nodes yet. Geographic distribution will appear after nodes join." + }, + "map": { + "sourceRequests": "Source requests in the last 24 hours {count}", + "presetPin": "Preset pin", + "requestsErrors": "Requests {requests} · errors {errors}", + "eventsStatus": "Active events {events} · node status {status}", + "openrestyStatus": "OpenResty status {status}", + "loadFailed": "Failed to load world map", + "loading": "Loading world map", + "loadFailedDesc": "The ECharts world map failed to register. Refresh and try again.", + "loadingDesc": "Initializing the world map after the first screen is ready.", + "measuring": "Measuring map container size..." + }, + "trafficTrend": { + "title": "24-hour request trend", + "description": "Hourly request totals and 2xx/4xx/5xx counts to spot abnormal status spikes.", + "summaryHint": "Last 24 hours", + "requests": "Requests", + "status2xx": "2xx requests", + "status4xx": "4xx requests", + "status5xx": "5xx requests" + }, + "capacityTrend": { + "title": "24-hour capacity trend", + "description": "Hourly CPU and memory usage to see whether cluster capacity stays tight.", + "avgCpu": "Avg CPU", + "avgMemory": "Avg memory" + }, + "trafficCapacity": { + "title": "24-hour traffic and capacity trend", + "networkHint": "Last 24 hours · from access logs", + "received": "Data received", + "provided": "Data served", + "capacityHint": "Last 24 hours · host capacity · average" + }, + "distributions": { + "sourceTitle": "Source distribution", + "sourceDesc": "Top source countries over the last 24 hours.", + "sourceEmpty": "No source distribution data", + "statusTitle": "Status code distribution", + "statusDesc": "Check whether successful responses still dominate and whether errors are rising.", + "statusEmpty": "No status code distribution", + "httpLabel": "HTTP {code}", + "domainTitle": "Top Domain", + "domainDesc": "See which domains concentrate most of the traffic.", + "domainEmpty": "No domain distribution" + }, + "nodeHealth": { + "title": "Node health", + "description": "Sorted by active incidents and resource pressure.", + "openNodes": "Open nodes", + "emptyTitle": "No nodes", + "emptyDesc": "After nodes join, this list will show health and capacity summaries.", + "colNode": "Node", + "colStatus": "Status", + "colCpuMem": "CPU / Memory", + "colReqErr": "Requests / Errors", + "colEvents": "Active incidents", + "colHeartbeat": "Last heartbeat", + "colActions": "Actions", + "noGeo": "Map point not configured", + "wsConnected": "WS connected", + "na": "N/A", + "detail": "Details" + }, + "nodeRank": { + "title": "Top nodes", + "traffic": "Highest traffic", + "trafficEmpty": "No traffic ranking", + "pressure": "Highest capacity pressure", + "pressureEmpty": "No capacity pressure data" + } + }, + "nodes": { + "title": "Nodes", + "applyLogs": "Apply logs", + "create": "Add node", + "listTitle": "Node list", + "refreshNow": "Refresh now", + "loadingList": "Loading nodes...", + "emptyAll": "No nodes yet. Create one to get started.", + "emptyFilter": "No nodes match the current filter", + "updated": "Node updated", + "created": "Node created", + "deleted": "Node deleted", + "deleteTitle": "Delete node", + "deleteDesc": "Delete node “{name}”? You will need to recreate and rejoin it.", + "deleting": "Deleting...", + "confirmDelete": "Delete", + "edit": "Edit", + "delete": "Delete", + "refresh": "Refresh", + "detail": "Details", + "na": "N/A", + "wsConnected": "WS connected", + "wsEstablished": "WebSocket connection established", + "requestFailed": "Request failed. Please try again later.", + "unknown": "Unknown", + "running": "Running", + "liveConfig": "Live config", + "notApplied": "Not applied", + "enabled": "Enabled", + "manual": "Manual", + "lockedSuffix": " (locked)", + "noGeo": "Map point not configured", + "notConfigured": "Not configured", + "ipLine": "IP: {ip}", + "locationLine": "Location: {location}", + "backToList": "Back to nodes", + "missingId": "Missing node ID. Open details from the node list.", + "loadingDetail": "Loading node details...", + "notFound": "Node not found. It may have been deleted or the ID is invalid.", + "syncing": "Syncing...", + "forceSync": "Force sync", + "dispatching": "Dispatching...", + "viewUpgrade": "View upgrade", + "tabOverview": "Overview", + "tabManage": "Status & deploy", + "kpiNodeId": "Node ID", + "kpiLastSeen": "Last heartbeat", + "kpiCurrentConfig": "Current config", + "filter": { + "all": "All nodes", + "descAll": "Showing all nodes.", + "descRelay": "Showing Relay nodes only.", + "descTunnel": "Showing Tunnel nodes only.", + "descEdge": "Showing Edge nodes only." + }, + "status": { + "online": "Online", + "pending": "Pending", + "offline": "Offline", + "running": "Running", + "wsConnected": "WS connected" + }, + "apply": { + "success": "Success", + "warning": "Warning", + "failed": "Failed", + "none": "N/A" + }, + "health": { + "healthy": "Healthy", + "unhealthy": "Unhealthy", + "unknown": "Unknown" + }, + "relative": { + "justNow": "Just now", + "minutesAgo": "{count} minutes ago", + "hoursAgo": "{count} hours ago", + "daysAgo": "{count} days ago", + "monthsAgo": "{count} months ago" + }, + "uptime": { + "daysHours": "{days}d {hours}h", + "hoursMinutes": "{hours}h {minutes}m", + "minutes": "{minutes}m" + }, + "channel": { + "preview": "preview", + "stable": "stable" + }, + "table": { + "node": "Node", + "status": "Status", + "version": "Version", + "runHealth": "Runtime health", + "currentVersion": "Current version", + "latestApply": "Latest apply", + "lastSeen": "Last heartbeat", + "actions": "Actions" + }, + "editor": { + "editTitle": "Edit node", + "createTitle": "Add node", + "description": "After creating a node, open its details for a dedicated token and deploy command.", + "nodeType": "Node type", + "typeEdge": "Edge node", + "typeRelay": "Relay node", + "typeTunnel": "Tunnel node", + "name": "Node name", + "namePlaceholder": "e.g. edge-hk-01", + "ip": "Node IP", + "ipPlaceholder": "Optional; reported automatically after join", + "lockIp": "Lock IP", + "lockIpDesc": "When enabled, the control plane will not overwrite the IP", + "autoUpdate": "Auto-update Agent", + "autoUpdateDesc": "When enabled, the node pulls stable updates automatically", + "bindPort": "Bind port", + "vhostPort": "VHost port", + "manualGeo": "Manual map point", + "manualGeoDesc": "Used on the overview map", + "geoName": "Location name", + "latitude": "Latitude", + "longitude": "Longitude", + "saving": "Saving...", + "saveEdit": "Save changes", + "createAction": "Add node", + "errName": "Enter a node name", + "errLockIp": "A node IP is required when IP is locked", + "errBindPort": "Enter a valid bind port", + "errVhostPort": "Enter a valid VHost port" + }, + "install": { + "edgeTitle": "Edge node deploy", + "edgeDesc": "Use the Agent Token to join an edge node to the control plane.", + "relayTitle": "Relay deploy command", + "relayDesc": "Use the Discovery Token to join an frps relay node to the control plane.", + "tunnelTitle": "Tunnel deploy command", + "tunnelDesc": "Use the Tunnel Token to join an openflared client to the control plane.", + "agentToken": "Agent Token", + "discoveryToken": "Discovery Token", + "tunnelToken": "Tunnel Token", + "scriptLabel": "One-line script (Linux / macOS)", + "dockerLabel": "Docker container deploy", + "copyFailed": "Copy failed. Select the command text manually.", + "copiedScript": "Deploy script copied", + "copiedDocker": "Docker deploy command copied", + "copyScript": "Copy script", + "copyDocker": "Copy Docker", + "nodeId": "Node ID", + "serverUrl": "Server URL (control plane)", + "serverUrlHint": "The script and container will use this address to reach the OpenFlare control plane.", + "noToken": "This node has no access token yet. Refresh later.", + "needServerUrl": "Enter a valid Server URL to generate the deploy command." + }, + "update": { + "title": "Agent upgrade", + "description": "Check GitHub releases and dispatch an upgrade. The node runs it after the next heartbeat.", + "currentVersion": "Current version", + "channel": "Release channel", + "updateStatus": "Update status", + "waitingPreview": "Waiting for preview update", + "waitingUpdate": "Waiting for update", + "notRequested": "Not dispatched", + "checking": "Checking releases...", + "hasUpdate": "Update available", + "upToDate": "Already up to date", + "previewRelease": "Preview release", + "stableRelease": "Stable release", + "targetVersion": "Target version", + "notFound": "Not found", + "publishedAt": "Published", + "noNotes": "No release notes", + "viewRelease": "View release", + "checkStable": "Check stable", + "checkPreview": "Check preview", + "upgradePreview": "Upgrade to preview", + "upgradeStable": "Upgrade to stable" + }, + "edge": { + "upgrade": "Upgrade Agent", + "restartOpenresty": "Restart OpenResty", + "runStatus": "Runtime status", + "runStatusDesc": "Node online state and OpenResty health", + "openrestyHealth": "OpenResty health", + "lastSeen": "Last heartbeat", + "ip": "IP address", + "geo": "Map point", + "syncTitle": "Config sync", + "syncDesc": "Version catch-up and latest apply result", + "currentVersion": "Current config version", + "latestApply": "Latest apply", + "latestApplyAt": "Latest apply time", + "agentVersion": "Agent version", + "nginxVersion": "Nginx version", + "autoUpdate": "Auto update", + "runtimeMessages": "Runtime messages", + "runtimeMessagesDesc": "Status notes reported by OpenResty and Agent", + "openrestyMessage": "OpenResty status message", + "noExtraError": "No extra error reported.", + "restartPending": "Waiting for the node to restart OpenResty after the next heartbeat.", + "kpiAgent": "Agent version", + "connectionHint": "Current OpenResty connections", + "toastUpdated": "Node updated", + "toastForceSync": "Force-sync dispatched to node {name}", + "toastRestart": "OpenResty restart dispatched to node {name}", + "toastUpgrade": "{channel} upgrade dispatched to node {name}", + "toastDeleted": "Node deleted" + }, + "relay": { + "upgrade": "Upgrade Relay", + "runStatus": "Relay runtime", + "runStatusDesc": "frps online state and load summary", + "health": "Relay health", + "connections": "Active connections / proxies", + "portsTitle": "Network ports", + "portsDesc": "frps control plane and HTTP vhost settings", + "bindPort": "Control bind port", + "vhostPort": "VHost HTTP port", + "agentAddr": "Agent access address", + "relayVersion": "Relay version", + "frpsVersion": "frps core version", + "webuiTitle": "FRPS WebUI", + "webuiDesc": "Enable the built-in frps web console and set its listen port", + "enableWebui": "Enable web console", + "enableWebuiHint": "When on, the node starts the built-in frps admin service.", + "webuiPort": "Web console port", + "webuiPortPlaceholder": "e.g. 17500", + "actualPort": "Currently listening on {port} (takes effect on the next heartbeat; default is bind port + 500).", + "openWebui": "Open FRPS WebUI", + "copiedLink": "Link copied", + "copyLink": "Copy link", + "webuiDisabled": "WebUI is disabled or the bind port is not set.", + "metadata": "Node metadata", + "createdAt": "Created", + "updatedAt": "Updated", + "kpiFrps": "frps core", + "connectionHint": "Relay active connections", + "invalidPort": "Port must be between 1 and 65535", + "portSaveFailed": "Failed to update port", + "toastPortSaved": "FRPS WebUI port updated", + "toastUpdated": "Relay node updated", + "toastWebui": "FRPS WebUI setting updated", + "toastForceSync": "Force-sync dispatched to relay {name}", + "toastUpgrade": "{channel} upgrade dispatched to relay {name}", + "toastDeleted": "Relay node deleted", + "deleteTitle": "Delete relay node", + "deleteDesc": "Delete relay node “{name}”? You will need to recreate and rejoin it." + }, + "tunnel": { + "upgrade": "Upgrade openflared", + "runStatus": "Tunnel runtime", + "runStatusDesc": "openflared connection and online state", + "flaredStatus": "flared status", + "autoUpdate": "Auto update", + "syncTitle": "Config sync", + "syncDesc": "Tunnel client config catch-up", + "currentVersion": "Current config version", + "latestApply": "Latest apply", + "latestApplyAt": "Latest apply time", + "fileCount": "Synced files", + "fileCountValue": "{count}", + "upgradeStatus": "Upgrade status", + "credentials": "Join credentials", + "credentialsDesc": "Token required by the tunnel client", + "tunnelToken": "Tunnel Token", + "createdAt": "Created", + "updatedAt": "Updated", + "kpiOpenflared": "openflared", + "connectionHint": "Tunnel concurrent connections", + "toastUpdated": "Tunnel node updated", + "toastForceSync": "Force-sync dispatched to tunnel {name}", + "toastUpgrade": "{channel} upgrade dispatched to tunnel {name}", + "toastDeleted": "Tunnel node deleted", + "deleteTitle": "Delete tunnel node", + "deleteDesc": "Delete tunnel node “{name}”? You will need to recreate and rejoin it." + }, + "obs": { + "hostname": "Hostname", + "os": "Operating system", + "kernelArch": "Kernel / arch", + "uptime": "Uptime", + "cpu": "CPU", + "cpuCores": "{count} cores", + "totalMemory": "Total memory", + "totalStorage": "Total storage", + "reportedAt": "Reported at", + "eventsTitle": "Health event timeline", + "eventsDesc": "Keeps active and recovered events to help judge runtime health.", + "cleanupLogs": "Clean logs", + "filterAll": "All events", + "filterActive": "Active", + "filterResolved": "Recovered", + "filterEmpty": "No health events for this filter.", + "noMessage": "No detailed message", + "firstTriggered": "First triggered:", + "lastTriggered": "Last triggered:", + "resolvedAt": "Recovered:", + "noEvents": "This node has not reported any health events yet.", + "cleaned": "Cleaned {count} health event logs", + "nothingToClean": "No health event logs to clean", + "loading": "Loading observability data...", + "diag": "Runtime diagnosis", + "activeIssues": "{count} active incidents", + "stable": "Stable", + "noActiveEvents": "No active health events", + "systemCore": "System core", + "uptimeFromProfile": "From the node system profile", + "profileTitle": "System profile", + "profileDesc": "Host and hardware info reported by the node", + "noProfile": "The node has joined but has not reported a full system profile yet.", + "noProfileLong": "The node has joined but has not reported a full system profile yet.", + "snapshotTitle": "Live resource snapshot", + "snapshotDesc": "Latest metrics report", + "realtimeResources": "Live resources", + "memory": "Memory", + "storage": "Storage", + "connections": "Connections", + "snapshotAgo": "Snapshot {time}", + "noSnapshot": "The node has joined but has not reported a resource snapshot yet.", + "noSnapshotLong": "The node has joined but has not reported a resource snapshot yet.", + "cleanupTitle": "Clean health event logs", + "cleanupDesc": "This deletes all health diagnosis history recorded for this node on the control plane. New events will still be reported.", + "cleaning": "Cleaning...", + "confirmCleanup": "Clean", + "windowRequests": "Window requests", + "windowVisitorsHint": "Window unique visitors {visitors} · error rate {rate}%", + "noWindowSummary": "No request window summary", + "capacityPressure": "Capacity pressure", + "needsAttention": "Needs attention", + "normalRange": "Normal", + "cpuStorage": "CPU {cpu} · storage {storage}", + "noResourceSnapshot": "No resource snapshot", + "sourceSignal": "Source signal", + "requestTimes": "{count} requests", + "noSourceDist": "No source distribution data", + "systemInfo": "System info", + "networkTraffic": "Network traffic", + "noActiveIssue": "No active incidents", + "openrestyConn": "OpenResty connections", + "currentConn": "Current connections:", + "throughputHint": "See access-log trends for business throughput (served / received)", + "windowVisitors": "Window unique visitors", + "reqQps": "Requests {requests} · estimated QPS {qps}", + "noTrafficSummary": "No window traffic summary", + "windowErrors": "Window errors", + "errorRate": "Error rate {rate}%", + "noErrorSummary": "No error-rate summary", + "noNetworkSnapshot": "The node has joined but has not reported a network traffic snapshot yet.", + "trafficTrendDesc": "Hourly request and error counts for this node.", + "capacityTrendDesc": "CPU and memory usage for this node over 24 hours.", + "structureTitle": "Request structure", + "structureDesc": "Last 24 hours: which status codes and domains concentrate traffic and errors.", + "mainStatus": "Top status code", + "times": "{count} times", + "noStatusDist": "No status code distribution", + "topDomain": "Top Domain", + "noDomainDist": "No domain distribution", + "resolvedEvents": "Recovered events", + "resolvedHint": "Health events recovered in the last 24 hours", + "statusDist": "Status code distribution", + "distEmpty": "No distribution data", + "unknownLabel": "Unknown" + }, + "networkTrend": { + "title": "24-hour network trend", + "description": "Hourly data served / received from access logs; summary is the last-24h total.", + "summaryHint": "Last 24 hours", + "received": "Data received", + "provided": "Data served" + }, + "diskIo": { + "title": "24-hour disk IO trend", + "description": "Hourly disk read/write rate (B/s) to spot log amplification, cache jitter, or disk pressure.", + "summaryHint": "Last 24 hours · average rate", + "read": "Disk read", + "write": "Disk write" + } + }, + "performance": { + "title": "Performance", + "description": "Manage global OpenResty performance settings. Saving enters the unified config publish flow.", + "preview": "View config preview", + "saved": "Performance settings saved", + "saveFailed": "Save failed", + "loadingAuth": "Loading permissions...", + "forbiddenTitle": "Insufficient permissions", + "forbiddenDesc": "Only administrators can access performance settings.", + "loading": "Loading performance settings...", + "runtimeTitle": "Connections and events", + "runtimeDesc": "Workers, timeouts, and request body size", + "proxyTitle": "Proxy buffers and timeouts", + "proxyDesc": "Upstream connections, buffers, and WebSocket/HTTP3 switches", + "gzipTitle": "Compression", + "cacheTitle": "Cache", + "cacheDesc": "Single-node reverse-proxy cache", + "errors": { + "workerProcesses": "worker_processes must be auto or an integer greater than 0", + "timeouts": "Timeout and connection values must be integers greater than 0", + "statusCode": "Default server return status must be between 100 and 999", + "bodySize": "Invalid client_max_body_size format", + "headerBuffers": "large_client_header_buffers must look like \"4 16k\"", + "proxyTimeouts": "Proxy timeouts must be integers greater than 0 seconds", + "proxyBuffers": "proxy_buffers must look like \"16 16k\"", + "bufferSize": "Buffer sizes must be integers or values with k/m/g units", + "gzipMinLength": "gzip_min_length must be an integer greater than 0", + "gzipLevel": "gzip_comp_level must be between 1 and 9", + "cachePath": "proxy_cache_path is required when cache is enabled", + "cacheFormat": "Invalid cache levels, inactive, max_size, or lock_timeout format", + "cacheKey": "A cache key template is required when cache is enabled" + } + }, + "applyLogs": { + "title": "Apply logs", + "description": "Inspect successful, warning, and failed config applies. Filter by node_id.", + "refresh": "Refresh", + "clear": "Clear logs", + "backToNodes": "Back to nodes", + "totalRecords": "Total records", + "currentPage": "Current page", + "totalPages": "Total pages", + "pageNodes": "Nodes on this page", + "nodeId": "Node ID", + "nodePlaceholder": "Filter apply logs by node_id", + "pageSize": "Page size", + "pageSizeOption": "{count}", + "filter": "Filter", + "reset": "Reset filters", + "emptyTitle": "No apply logs", + "emptyDesc": "No apply records match the current filters.", + "colNodeId": "Node ID", + "colVersion": "Version", + "colResult": "Result", + "colChecksum": "Checksum", + "colTime": "Time", + "colMessage": "Message", + "colActions": "Actions", + "detail": "Details", + "pageInfo": "Page {current} / {total}, {count} records.", + "prev": "Previous", + "next": "Next", + "loadFailed": "Load failed", + "cleared": "Apply logs cleared", + "clearedDesc": "Deleted {count} records", + "clearFailed": "Failed to clear apply logs", + "unknownError": "Unknown error", + "clearTitle": "Clear all apply logs?", + "clearDesc": "This deletes all {count} apply log records and cannot be undone. Nodes will continue to report new apply results.", + "clearing": "Clearing...", + "confirmClear": "Clear", + "success": "Success", + "warning": "Warning", + "failed": "Failed", + "sheetTitle": "Apply log details", + "sheetDesc": "Full result and checksum details for this apply log.", + "versionBadge": "Version: {version}", + "createdAt": "Created", + "targetChecksum": "Target Checksum", + "mainChecksum": "Main config digest", + "routeChecksum": "Route config digest", + "supportFiles": "Support files", + "message": "Message", + "close": "Close" + }, + "configVersions": { + "title": "Release versions", + "description": "Browse historical snapshots, preview pending diffs, and reactivate an older version when needed.", + "refresh": "Refresh", + "cleanupOld": "Clean", + "viewDiff": "Diff", + "forcePublish": "Force publish", + "previewPublish": "Publish", + "emptyTitle": "No history", + "emptyDesc": "There are no published versions yet. Publish a config first.", + "colVersion": "Version", + "colStatus": "Status", + "colAuthor": "Created by", + "colChecksum": "Checksum", + "colCreatedAt": "Created", + "colActions": "Actions", + "active": "Active", + "history": "History", + "system": "System", + "snapshot": "Snapshot", + "activate": "Activate", + "loadFailed": "Load failed", + "previewLoadFailed": "Failed to load preview", + "diffLoadFailed": "Failed to load diff", + "publishSuccess": "Published", + "publishSuccessDesc": "Version {version}", + "publishFailed": "Publish failed", + "activateSuccess": "Activated", + "activateSuccessDesc": "Version {version}", + "activateFailed": "Activate failed", + "cleanupDone": "Cleanup complete", + "cleanupDoneDesc": "Deleted {count} historical snapshots", + "cleanupFailed": "Cleanup failed", + "unknownError": "Unknown error", + "confirmPublishTitle": "Publish config", + "confirmPublishDesc": "This creates a new version from the pending config and marks it active. Nodes will pull the update next.", + "confirmPublish": "Publish", + "confirmForceTitle": "Force publish", + "confirmForceDesc": "This ignores the change check and immediately creates a new version. Confirm you really need to republish.", + "confirmActivateTitle": "Activate version", + "confirmActivateDesc": "Set version {version} as the active version?", + "confirmActivate": "Activate", + "previewTitle": "Publish preview", + "previewDesc": "Rendered pending config and support files.", + "loadingPreview": "Loading preview...", + "routeCount": "{count} routes", + "websiteCount": "{count} websites", + "mainConfig": "Main config", + "routeConfig": "Route config", + "supportFiles": "Support files ({count})", + "noSupportFiles": "This publish has no extra support files.", + "noChanges": "Pending routes match the active version, so it cannot be published again.", + "close": "Close", + "publishing": "Publishing...", + "diffTitle": "Config diff", + "diffDesc": "Differences between the pending config and the active version.", + "loadingDiff": "Loading diff...", + "activeVersion": "Active version", + "none": "None", + "addedDomains": "Added domains", + "removedDomains": "Removed domains", + "modifiedDomains": "Modified domains", + "itemCount": "{count}", + "noChange": "No changes", + "emptyValue": "empty", + "mainChanged": "Main config changed", + "mainUnchanged": "Main config unchanged", + "wafChanged": "WAF changed", + "wafUnchanged": "WAF unchanged", + "websiteDelta": "Websites {from} → {to}", + "optionKey": "Option", + "activeValue": "Active value", + "pendingValue": "Pending value", + "noOptionChanges": "No OpenResty option changes.", + "snapshotTitle": "Version snapshot", + "snapshotVersion": "Version {version}", + "snapshotDesc": "Full snapshot of this historical config version.", + "loadingSnapshot": "Loading snapshot...", + "createdBy": "Created by", + "createdAt": "Created", + "snapshotLoadFailed": "Failed to load version snapshot", + "cleanupTitle": "Clean historical snapshots", + "cleanupDesc": "Delete older snapshots. The currently active version is always protected.", + "keepCount": "Keep latest snapshots", + "keepHint": "Default 10, minimum 3.", + "keepMin": "Keep at least 3 historical snapshots", + "cleaning": "Cleaning...", + "confirmCleanup": "Clean" + } +} diff --git a/frontend/messages/fragments/ops.zh-CN.json b/frontend/messages/fragments/ops.zh-CN.json new file mode 100644 index 00000000..4909558c --- /dev/null +++ b/frontend/messages/fragments/ops.zh-CN.json @@ -0,0 +1,673 @@ +{ + "dashboard": { + "title": "总览", + "generatedAt": "数据生成于 {time}", + "refresh": "刷新", + "loadingTitle": "加载总览数据", + "loadingDesc": "正在聚合节点健康、流量与容量指标...", + "loadFailed": "总览看板加载失败:{error}", + "emptyTitle": "暂无总览数据", + "emptyDesc": "系统已经启动,但还没有可展示的总览聚合结果。", + "requestFailed": "请求失败,请稍后重试。", + "stats": { + "requests24h": "24h 请求量", + "requestsMeta": "24h 独立访客 {visitors} · 错误 {errors} · 估算 QPS {qps}", + "clusterCapacity": "集群容量", + "avgMemoryHighLoad": "平均内存 {memory} · 高负载节点 CPU {cpu} / 内存 {memoryNodes} / 存储 {storage}", + "nodeOverview": "节点概览", + "onlineOf": "/ {total} 在线", + "pending": "待接入 {count}", + "unhealthyOffline": "异常 {unhealthy} · 离线 {offline}" + }, + "world": { + "title": "全球态势板", + "description": "汇总节点分布、来源国家热度与最近窗口运行状态。", + "legendSource": "来源", + "legendHealthy": "正常", + "legendPressure": "承压", + "legendUnhealthy": "异常", + "modeVisitorSource": "访客来源", + "modeNodeCoords": "节点坐标", + "modeCoverage": "覆盖信号", + "onlineCoverage": "在线覆盖", + "onlineHint": "{online}/{total} 在线", + "runHealth": "运行健康", + "unhealthyCount": "{count} 个异常", + "requests24h": "24小时请求", + "qpsReported": "QPS {qps} · {count} 个节点上报", + "nodeCoords": "节点坐标", + "sourceCountries": "{count} 个来源国家", + "fallbackPins": "未配置时使用预设落点", + "avgCpu": "平均 CPU", + "highCount": "{count} 个偏高", + "avgMemory": "平均内存", + "highStorage": "高存储", + "offlinePending": "{offline} 离线 · {pending} 待接入", + "mapPreparing": "地图准备中", + "mapPreparingDesc": "进入可视区域后加载", + "noNodesOverlay": "暂无节点,接入后将展示地理分布" + }, + "map": { + "sourceRequests": "最近 24 小时来源请求 {count}", + "presetPin": "预设落点", + "requestsErrors": "请求量 {requests} · 错误数 {errors}", + "eventsStatus": "活动事件 {events} · 节点状态 {status}", + "openrestyStatus": "OpenResty 状态 {status}", + "loadFailed": "全球地图加载失败", + "loading": "全球地图加载中", + "loadFailedDesc": "ECharts 世界地图资源未能成功注册,请稍后刷新重试。", + "loadingDesc": "正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。", + "measuring": "正在测量地图容器尺寸..." + }, + "trafficTrend": { + "title": "24 小时请求趋势", + "description": "按小时拆分请求总量与 2xx/4xx/5xx 状态码请求量,判断各状态是否异常抬升。", + "summaryHint": "近 24 小时", + "requests": "请求量", + "status2xx": "2xx 请求", + "status4xx": "4xx 请求", + "status5xx": "5xx 请求" + }, + "capacityTrend": { + "title": "24 小时容量趋势", + "description": "按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。", + "avgCpu": "平均 CPU", + "avgMemory": "平均内存" + }, + "trafficCapacity": { + "title": "24 小时业务流量与容量趋势", + "networkHint": "近 24 小时 · 来自访问日志", + "received": "接收数据", + "provided": "已提供数据", + "capacityHint": "近 24 小时 · 宿主机容量 · 平均值" + }, + "distributions": { + "sourceTitle": "来源分布", + "sourceDesc": "聚合最近 24 小时主要来源国家。", + "sourceEmpty": "暂无来源分布数据", + "statusTitle": "状态码分布", + "statusDesc": "快速判断成功响应是否仍是主流,以及错误码是否有抬升。", + "statusEmpty": "暂无状态码分布", + "httpLabel": "HTTP {code}", + "domainTitle": "Top Domain", + "domainDesc": "观察主要流量集中在哪些域名。", + "domainEmpty": "暂无域名分布" + }, + "nodeHealth": { + "title": "节点健康列表", + "description": "按异常数量和资源压力排序。", + "openNodes": "进入节点页", + "emptyTitle": "暂无节点", + "emptyDesc": "节点接入后,这里会展示系统健康与容量摘要。", + "colNode": "节点", + "colStatus": "状态", + "colCpuMem": "CPU / 内存", + "colReqErr": "请求 / 错误", + "colEvents": "活动异常", + "colHeartbeat": "最近心跳", + "colActions": "操作", + "noGeo": "未配置地图点位", + "wsConnected": "WS 已连接", + "na": "暂无", + "detail": "详情" + }, + "nodeRank": { + "title": "Top 节点榜单", + "traffic": "流量最高节点", + "trafficEmpty": "暂无流量榜单", + "pressure": "容量压力节点", + "pressureEmpty": "暂无容量压力数据" + } + }, + "nodes": { + "title": "节点管理", + "applyLogs": "应用记录", + "create": "新增节点", + "listTitle": "节点列表", + "refreshNow": "立即刷新", + "loadingList": "加载节点列表中...", + "emptyAll": "暂无节点,请先创建一个节点。", + "emptyFilter": "当前筛选无结果", + "updated": "节点已更新", + "created": "节点已创建", + "deleted": "节点已删除", + "deleteTitle": "确认删除节点", + "deleteDesc": "确认删除节点「{name}」吗?删除后该节点需要重新创建并重新接入。", + "deleting": "删除中...", + "confirmDelete": "确认删除", + "edit": "编辑", + "delete": "删除", + "refresh": "刷新", + "detail": "详情", + "na": "暂无", + "wsConnected": "WS 已连接", + "wsEstablished": "WebSocket 长连接已建立", + "requestFailed": "请求失败,请稍后重试。", + "unknown": "未知", + "running": "运行中", + "liveConfig": "实时配置", + "notApplied": "未应用", + "enabled": "已启用", + "manual": "手动", + "lockedSuffix": "(已锁定)", + "noGeo": "未配置地图点位", + "notConfigured": "未配置", + "ipLine": "IP:{ip}", + "locationLine": "位置:{location}", + "backToList": "返回节点列表", + "missingId": "缺少节点 ID,请从节点列表进入详情页。", + "loadingDetail": "加载节点详情中...", + "notFound": "节点不存在,可能已被删除或 ID 无效。", + "syncing": "同步中...", + "forceSync": "强制同步", + "dispatching": "下发中...", + "viewUpgrade": "查看升级", + "tabOverview": "概览", + "tabManage": "状态与部署", + "kpiNodeId": "节点 ID", + "kpiLastSeen": "最近心跳", + "kpiCurrentConfig": "当前配置", + "filter": { + "all": "全部节点", + "descAll": "当前展示全部节点。", + "descRelay": "当前仅展示 Relay 节点。", + "descTunnel": "当前仅展示 Tunnel 节点。", + "descEdge": "当前仅展示 Edge 节点。" + }, + "status": { + "online": "在线", + "pending": "待接入", + "offline": "离线", + "running": "运行中", + "wsConnected": "WS 已连接" + }, + "apply": { + "success": "成功", + "warning": "警告", + "failed": "失败", + "none": "暂无" + }, + "health": { + "healthy": "健康", + "unhealthy": "异常", + "unknown": "未知" + }, + "relative": { + "justNow": "刚刚", + "minutesAgo": "{count} 分钟前", + "hoursAgo": "{count} 小时前", + "daysAgo": "{count} 天前", + "monthsAgo": "{count} 个月前" + }, + "uptime": { + "daysHours": "{days} 天 {hours} 小时", + "hoursMinutes": "{hours} 小时 {minutes} 分钟", + "minutes": "{minutes} 分钟" + }, + "channel": { + "preview": "预览版", + "stable": "正式版" + }, + "table": { + "node": "节点", + "status": "状态", + "version": "Version", + "runHealth": "运行健康", + "currentVersion": "当前版本", + "latestApply": "最近应用", + "lastSeen": "最近心跳", + "actions": "操作" + }, + "editor": { + "editTitle": "编辑节点", + "createTitle": "新增节点", + "description": "预创建节点后可在详情页查看专属 Token 与部署命令。", + "nodeType": "节点类型", + "typeEdge": "Edge 节点", + "typeRelay": "Relay 节点", + "typeTunnel": "Tunnel 节点", + "name": "节点名称", + "namePlaceholder": "例如 edge-hk-01", + "ip": "节点 IP", + "ipPlaceholder": "可选,接入后自动上报", + "lockIp": "锁定 IP", + "lockIpDesc": "启用后管理端不再自动覆盖 IP", + "autoUpdate": "自动更新 Agent", + "autoUpdateDesc": "启用后节点将自动拉取正式版更新", + "bindPort": "绑定端口", + "vhostPort": "VHost 端口", + "manualGeo": "手动地图点位", + "manualGeoDesc": "用于总览地图展示", + "geoName": "位置名称", + "latitude": "纬度", + "longitude": "经度", + "saving": "保存中...", + "saveEdit": "保存修改", + "createAction": "新增节点", + "errName": "请输入节点名称", + "errLockIp": "锁定 IP 时必须填写节点 IP", + "errBindPort": "请输入有效的绑定端口", + "errVhostPort": "请输入有效的 VHost 端口" + }, + "install": { + "edgeTitle": "边缘节点部署", + "edgeDesc": "使用 Agent Token 将边缘节点接入控制端。", + "relayTitle": "中继部署命令", + "relayDesc": "使用 Discovery Token 将 frps 中继节点接入控制端。", + "tunnelTitle": "隧道部署命令", + "tunnelDesc": "使用 Tunnel Token 将 openflared 客户端接入控制端。", + "agentToken": "Agent Token", + "discoveryToken": "Discovery Token", + "tunnelToken": "Tunnel Token", + "scriptLabel": "一键脚本部署 (Linux / macOS)", + "dockerLabel": "Docker 容器部署", + "copyFailed": "复制失败,请手动选择命令文本。", + "copiedScript": "部署脚本命令已复制", + "copiedDocker": "Docker 部署命令已复制", + "copyScript": "复制脚本", + "copyDocker": "复制 Docker", + "nodeId": "Node ID", + "serverUrl": "Server URL(控制端地址)", + "serverUrlHint": "部署脚本和容器将使用此地址连接 OpenFlare 控制端。", + "noToken": "节点尚未生成接入 Token,请稍后刷新。", + "needServerUrl": "请填写有效的 Server URL 以生成部署命令。" + }, + "update": { + "title": "Agent 升级", + "description": "检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。", + "currentVersion": "当前版本", + "channel": "检查通道", + "updateStatus": "更新状态", + "waitingPreview": "等待预览更新", + "waitingUpdate": "等待更新", + "notRequested": "未下发", + "checking": "检查发布版本中...", + "hasUpdate": "发现可升级版本", + "upToDate": "当前已是最新版本", + "previewRelease": "Preview 发布", + "stableRelease": "正式发布", + "targetVersion": "目标版本", + "notFound": "未找到", + "publishedAt": "发布时间", + "noNotes": "暂无更新说明", + "viewRelease": "查看发布详情", + "checkStable": "检查正式版", + "checkPreview": "检查预览版", + "upgradePreview": "升级到预览版", + "upgradeStable": "升级到正式版" + }, + "edge": { + "upgrade": "升级 Agent", + "restartOpenresty": "重启 OpenResty", + "runStatus": "运行状态", + "runStatusDesc": "节点在线情况与 OpenResty 健康", + "openrestyHealth": "OpenResty 健康", + "lastSeen": "最近心跳", + "ip": "IP 地址", + "geo": "地图点位", + "syncTitle": "配置同步", + "syncDesc": "版本追平与应用结果摘要", + "currentVersion": "当前配置版本", + "latestApply": "最近应用", + "latestApplyAt": "最近应用时间", + "agentVersion": "Agent 版本", + "nginxVersion": "Nginx 版本", + "autoUpdate": "自动更新", + "runtimeMessages": "运行消息", + "runtimeMessagesDesc": "OpenResty 与 Agent 上报的状态说明", + "openrestyMessage": "OpenResty 状态消息", + "noExtraError": "当前未上报额外错误。", + "restartPending": "已等待节点在下一次心跳后执行 OpenResty 重启。", + "kpiAgent": "Agent 版本", + "connectionHint": "OpenResty 当前连接", + "toastUpdated": "节点已更新", + "toastForceSync": "已向节点 {name} 下发强制同步指令", + "toastRestart": "已向节点 {name} 下发 OpenResty 重启指令", + "toastUpgrade": "已向节点 {name} 下发{channel}升级指令", + "toastDeleted": "节点已删除" + }, + "relay": { + "upgrade": "升级 Relay", + "runStatus": "中继运行状态", + "runStatusDesc": "frps 在线情况与负载摘要", + "health": "中继健康", + "connections": "活动连接 / 代理数", + "portsTitle": "网络端口", + "portsDesc": "frps 控制面与 HTTP 虚拟主机配置", + "bindPort": "绑定控制端口", + "vhostPort": "VHost HTTP 端口", + "agentAddr": "Agent 接入地址", + "relayVersion": "Relay 版本", + "frpsVersion": "frps 核心版本", + "webuiTitle": "FRPS WebUI", + "webuiDesc": "控制 frps 内置 Web 管理界面是否启用及其监听端口", + "enableWebui": "启用 Web 管理界面", + "enableWebuiHint": "开启后,节点将启动 frps 的内置管理服务。", + "webuiPort": "Web 管理界面端口", + "webuiPortPlaceholder": "例如: 17500", + "actualPort": "当前实际监听端口:{port} (修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。", + "openWebui": "打开 FRPS WebUI", + "copiedLink": "链接已复制到剪贴板", + "copyLink": "复制链接", + "webuiDisabled": "WebUI 已禁用或未配置绑定端口。", + "metadata": "节点元数据", + "createdAt": "创建时间", + "updatedAt": "更新时间", + "kpiFrps": "frps 核心", + "connectionHint": "中继承载活动连接数", + "invalidPort": "端口号必须在 1 ~ 65535 范围内", + "portSaveFailed": "更新端口失败", + "toastPortSaved": "FRPS WebUI 端口配置已更新", + "toastUpdated": "中继节点已更新", + "toastWebui": "FRPS WebUI 设置已更新", + "toastForceSync": "已向中继节点 {name} 下发强制同步指令", + "toastUpgrade": "已向中继节点 {name} 下发{channel}升级指令", + "toastDeleted": "中继节点已删除", + "deleteTitle": "确认删除中继节点", + "deleteDesc": "确认删除中继节点「{name}」吗?删除后该节点需要重新创建并重新接入。" + }, + "tunnel": { + "upgrade": "升级 openflared", + "runStatus": "隧道运行状态", + "runStatusDesc": "openflared 连接与在线情况", + "flaredStatus": "flared 状态", + "autoUpdate": "自动更新", + "syncTitle": "配置同步", + "syncDesc": "隧道客户端配置追平状态", + "currentVersion": "当前配置版本", + "latestApply": "最近应用", + "latestApplyAt": "最近应用时间", + "fileCount": "同步文件数", + "fileCountValue": "{count} 个", + "upgradeStatus": "升级状态", + "credentials": "接入凭证", + "credentialsDesc": "隧道客户端接入所需 Token", + "tunnelToken": "Tunnel Token", + "createdAt": "创建时间", + "updatedAt": "更新时间", + "kpiOpenflared": "openflared", + "connectionHint": "隧道并发连接数", + "toastUpdated": "隧道节点已更新", + "toastForceSync": "已向隧道节点 {name} 下发强制同步指令", + "toastUpgrade": "已向隧道节点 {name} 下发{channel}升级指令", + "toastDeleted": "隧道节点已删除", + "deleteTitle": "确认删除隧道节点", + "deleteDesc": "确认删除隧道节点「{name}」吗?删除后该节点需要重新创建并重新接入。" + }, + "obs": { + "hostname": "主机名", + "os": "操作系统", + "kernelArch": "内核 / 架构", + "uptime": "在线时长", + "cpu": "CPU", + "cpuCores": "{count} 核", + "totalMemory": "总内存", + "totalStorage": "总存储", + "reportedAt": "上报时间", + "eventsTitle": "健康事件时间线", + "eventsDesc": "保留活动与已恢复事件,帮助判断运行状态。", + "cleanupLogs": "清理日志", + "filterAll": "全部事件", + "filterActive": "活动中", + "filterResolved": "已恢复", + "filterEmpty": "当前筛选下没有健康事件。", + "noMessage": "暂无详细消息", + "firstTriggered": "首次触发:", + "lastTriggered": "最近触发:", + "resolvedAt": "恢复时间:", + "noEvents": "节点当前还没有上报健康事件记录。", + "cleaned": "已清理 {count} 条健康事件日志", + "nothingToClean": "当前没有可清理的健康事件日志", + "loading": "加载运行观测数据中...", + "diag": "运行诊断", + "activeIssues": "{count} 个活动异常", + "stable": "运行稳定", + "noActiveEvents": "当前没有活动中的健康事件", + "systemCore": "系统核心", + "uptimeFromProfile": "来自节点系统画像上报", + "profileTitle": "系统画像", + "profileDesc": "节点上报的主机与硬件信息", + "noProfile": "节点已接入,但尚未上报完整系统画像。", + "noProfileLong": "节点已经接入,但还没有上报完整系统画像。", + "snapshotTitle": "实时资源快照", + "snapshotDesc": "最近一次 metrics 上报", + "realtimeResources": "实时资源", + "memory": "内存", + "storage": "存储", + "connections": "连接数", + "snapshotAgo": "快照 {time}", + "noSnapshot": "节点已接入,但尚未上报资源快照。", + "noSnapshotLong": "节点已经接入,但还没有上报资源快照。", + "cleanupTitle": "清理健康事件日志", + "cleanupDesc": "该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。", + "cleaning": "清理中...", + "confirmCleanup": "确认清理", + "windowRequests": "查询窗口请求", + "windowVisitorsHint": "查询窗口独立访客 {visitors} · 错误率 {rate}%", + "noWindowSummary": "当前没有可展示的请求窗口摘要", + "capacityPressure": "容量压力", + "needsAttention": "需要关注", + "normalRange": "正常范围", + "cpuStorage": "CPU {cpu} · 存储 {storage}", + "noResourceSnapshot": "当前没有资源快照", + "sourceSignal": "来源信号", + "requestTimes": "{count} 次请求", + "noSourceDist": "当前没有来源分布数据", + "systemInfo": "系统信息", + "networkTraffic": "网络流量", + "noActiveIssue": "无活动异常", + "openrestyConn": "OpenResty 连接", + "currentConn": "当前连接:", + "throughputHint": "业务吞吐请看访问日志趋势(已提供/接收数据)", + "windowVisitors": "查询窗口独立访客", + "reqQps": "请求 {requests} · 估算 QPS {qps}", + "noTrafficSummary": "暂无窗口流量摘要", + "windowErrors": "查询窗口错误", + "errorRate": "错误率 {rate}%", + "noErrorSummary": "暂无错误率摘要", + "noNetworkSnapshot": "节点已经接入,但还没有上报网络流量相关快照。", + "trafficTrendDesc": "按小时聚合该节点的请求量和错误量。", + "capacityTrendDesc": "观察该节点 CPU 与内存使用率在 24 小时内的变化。", + "structureTitle": "请求结构分布", + "structureDesc": "聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。", + "mainStatus": "主状态码", + "times": "{count} 次", + "noStatusDist": "暂无状态码分布", + "topDomain": "Top Domain", + "noDomainDist": "暂无域名分布", + "resolvedEvents": "已恢复事件", + "resolvedHint": "最近 24 小时已恢复健康事件", + "statusDist": "状态码分布", + "distEmpty": "暂无分布数据", + "unknownLabel": "未知" + }, + "networkTrend": { + "title": "24 小时网络趋势", + "description": "按小时展示已提供/接收数据(访问日志);摘要为近 24 小时总量。", + "summaryHint": "近 24 小时", + "received": "接收数据", + "provided": "已提供数据" + }, + "diskIo": { + "title": "24 小时磁盘 IO 趋势", + "description": "按小时展示磁盘读写速率(B/s),辅助判断日志放大、缓存抖动或磁盘压力。", + "summaryHint": "近 24 小时 · 平均速率", + "read": "磁盘读", + "write": "磁盘写" + } + }, + "performance": { + "title": "性能", + "description": "集中管理 OpenResty 全局性能参数,保存后进入统一配置发布链路。", + "preview": "查看配置预览", + "saved": "性能参数已保存", + "saveFailed": "保存失败", + "loadingAuth": "加载权限信息...", + "forbiddenTitle": "权限不足", + "forbiddenDesc": "只有管理员可以访问性能设置。", + "loading": "加载性能参数...", + "runtimeTitle": "连接与事件", + "runtimeDesc": "worker、超时、请求体大小等运行参数", + "proxyTitle": "反代缓冲与超时", + "proxyDesc": "upstream 连接、缓冲与 WebSocket/HTTP3 开关", + "gzipTitle": "压缩", + "cacheTitle": "缓存", + "cacheDesc": "单节点反代缓存优化场景", + "errors": { + "workerProcesses": "worker_processes 必须为 auto 或大于 0 的整数", + "timeouts": "超时与连接参数必须为大于 0 的整数", + "statusCode": "空白页面返回状态码必须在 100 到 999 之间", + "bodySize": "client_max_body_size 格式不合法", + "headerBuffers": "large_client_header_buffers 格式必须类似 \"4 16k\"", + "proxyTimeouts": "代理超时参数必须为大于 0 的整数秒", + "proxyBuffers": "proxy_buffers 格式必须类似 \"16 16k\"", + "bufferSize": "缓冲大小必须为整数或带 k/m/g 单位的值", + "gzipMinLength": "gzip_min_length 必须为大于 0 的整数", + "gzipLevel": "gzip_comp_level 必须在 1 到 9 之间", + "cachePath": "启用缓存时必须填写 proxy_cache_path 目录", + "cacheFormat": "缓存 levels、inactive、max_size 或 lock_timeout 格式不合法", + "cacheKey": "启用缓存时必须填写缓存 Key 模板" + } + }, + "applyLogs": { + "title": "应用日志", + "description": "查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。", + "refresh": "刷新", + "clear": "清空日志", + "backToNodes": "返回节点", + "totalRecords": "总记录数", + "currentPage": "当前页", + "totalPages": "总页数", + "pageNodes": "当前页节点数", + "nodeId": "Node ID", + "nodePlaceholder": "输入 node_id 过滤应用日志", + "pageSize": "每页条数", + "pageSizeOption": "{count} 条", + "filter": "筛选", + "reset": "重置筛选", + "emptyTitle": "暂无应用日志", + "emptyDesc": "当前筛选条件下没有可展示的应用记录。", + "colNodeId": "Node ID", + "colVersion": "版本", + "colResult": "结果", + "colChecksum": "Checksum", + "colTime": "时间", + "colMessage": "消息", + "colActions": "操作", + "detail": "详情", + "pageInfo": "第 {current} / {total} 页,共 {count} 条记录。", + "prev": "上一页", + "next": "下一页", + "loadFailed": "加载失败", + "cleared": "应用日志已清空", + "clearedDesc": "共删除 {count} 条记录", + "clearFailed": "清空应用日志失败", + "unknownError": "未知错误", + "clearTitle": "确认清空应用日志?", + "clearDesc": "该操作将删除全部 {count} 条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。", + "clearing": "清空中...", + "confirmClear": "确认清空", + "success": "成功", + "warning": "警告", + "failed": "失败", + "sheetTitle": "应用日志详情", + "sheetDesc": "查看单条应用日志的完整结果与校验信息。", + "versionBadge": "版本: {version}", + "createdAt": "创建时间", + "targetChecksum": "目标 Checksum", + "mainChecksum": "主配置摘要", + "routeChecksum": "路由配置摘要", + "supportFiles": "支持文件数", + "message": "消息", + "close": "关闭" + }, + "configVersions": { + "title": "配置版本", + "description": "查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。", + "refresh": "刷新", + "cleanupOld": "清理旧版本", + "viewDiff": "查看差异", + "forcePublish": "强制发布", + "previewPublish": "预览并发布", + "emptyTitle": "暂无历史版本", + "emptyDesc": "当前还没有可查看的发布记录,请先触发一次配置发布。", + "colVersion": "版本号", + "colStatus": "状态", + "colAuthor": "创建人", + "colChecksum": "Checksum", + "colCreatedAt": "创建时间", + "colActions": "操作", + "active": "当前激活", + "history": "历史版本", + "system": "系统", + "snapshot": "快照", + "activate": "激活", + "loadFailed": "加载失败", + "previewLoadFailed": "加载预览失败", + "diffLoadFailed": "加载差异失败", + "publishSuccess": "发布成功", + "publishSuccessDesc": "版本 {version}", + "publishFailed": "发布失败", + "activateSuccess": "激活成功", + "activateSuccessDesc": "版本 {version}", + "activateFailed": "激活失败", + "cleanupDone": "清理完成", + "cleanupDoneDesc": "已删除 {count} 个历史快照", + "cleanupFailed": "清理失败", + "unknownError": "未知错误", + "confirmPublishTitle": "确认发布配置", + "confirmPublishDesc": "将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。", + "confirmPublish": "确认发布", + "confirmForceTitle": "确认强制发布", + "confirmForceDesc": "将忽略配置变化检查并立即生成一个新版本,请确认你确实需要重新发布。", + "confirmActivateTitle": "确认激活版本", + "confirmActivateDesc": "确认将版本 {version} 设为当前激活版本吗?", + "confirmActivate": "确认激活", + "previewTitle": "发布预览", + "previewDesc": "查看待发布配置的渲染结果与支持文件。", + "loadingPreview": "正在加载预览...", + "routeCount": "规则 {count} 条", + "websiteCount": "网站 {count} 个", + "mainConfig": "主配置", + "routeConfig": "路由配置", + "supportFiles": "支持文件 ({count})", + "noSupportFiles": "当前发布不需要额外支持文件。", + "noChanges": "当前规则与已激活版本一致,无法重复发布。", + "close": "关闭", + "publishing": "发布中...", + "diffTitle": "配置差异", + "diffDesc": "对比当前待发布配置与已激活版本之间的差异。", + "loadingDiff": "正在加载差异数据...", + "activeVersion": "激活版本", + "none": "无", + "addedDomains": "新增域名", + "removedDomains": "删除域名", + "modifiedDomains": "修改域名", + "itemCount": "{count} 项", + "noChange": "无变更", + "emptyValue": "空", + "mainChanged": "主配置 已变化", + "mainUnchanged": "主配置 无变化", + "wafChanged": "WAF 已变化", + "wafUnchanged": "WAF 无变化", + "websiteDelta": "网站数 {from} → {to}", + "optionKey": "参数", + "activeValue": "激活值", + "pendingValue": "待发布值", + "noOptionChanges": "当前无 OpenResty 参数变化。", + "snapshotTitle": "版本快照", + "snapshotVersion": "版本 {version}", + "snapshotDesc": "查看历史配置版本的完整快照内容。", + "loadingSnapshot": "正在加载快照...", + "createdBy": "创建人", + "createdAt": "创建时间", + "snapshotLoadFailed": "加载版本快照失败", + "cleanupTitle": "清理历史快照", + "cleanupDesc": "删除旧的历史快照配置,系统将始终保护当前已激活的版本不被删除。", + "keepCount": "保留最近快照个数", + "keepHint": "默认为 10 个,最少需保留 3 个。", + "keepMin": "最少保留 3 个历史快照", + "cleaning": "清理中...", + "confirmCleanup": "确认清理" + } +} diff --git a/frontend/messages/fragments/security.en.json b/frontend/messages/fragments/security.en.json new file mode 100644 index 00000000..a064b59a --- /dev/null +++ b/frontend/messages/fragments/security.en.json @@ -0,0 +1,1080 @@ +{ + "waf": { + "title": "WAF", + "createRule": "New rule", + "ruleGroups": "Rule groups", + "ruleGroupsDesc": "Build WAF protection rules with a visual flow. Global rules always apply to every site.", + "refresh": "Refresh", + "loading": "Loading WAF rule groups...", + "empty": "No rule groups yet. The system usually creates a global group automatically.", + "created": "Rule created", + "groupDeleted": "Rule group deleted", + "deleteTitle": "Delete rule group", + "deleteDesc": "Delete rule group “{name}”? This cannot be undone.", + "deleting": "Deleting...", + "confirmDelete": "Delete", + "operationFailed": "Operation failed", + "autoConfigMustBeObject": "Automatic configuration must be a JSON object.", + "autoConfigInvalid": "Invalid automatic configuration format", + "saveFailed": "Save failed", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enabled", + "disable": "Disabled", + "global": "Global", + "custom": "Custom", + "allSites": "All sites", + "siteCount": "{count} sites", + "columns": { + "name": "Name", + "type": "Type", + "status": "Status", + "nodeCount": "Nodes", + "scope": "Scope", + "updatedAt": "Updated", + "actions": "Actions" + }, + "actions": { + "compose": "Edit", + "delete": "Delete" + }, + "createDialog": { + "title": "New WAF rule", + "description": "After creation you will open the editor and configure the processing flow.", + "name": "Rule name", + "namePlaceholder": "e.g. Edge protection", + "submit": "Create and edit" + }, + "editor": { + "missingId": "A valid rule ID is required.", + "loadFailed": "Failed to load the rule. Please try again.", + "reload": "Reload", + "back": "Back", + "graphValid": "Graph is valid", + "issueCount": "{count} issues", + "unsaved": "Unsaved", + "enableRule": "Enable rule", + "format": "Format", + "formatTitle": "Auto-arrange nodes and edges", + "saving": "Saving...", + "save": "Save", + "saved": "Rule graph saved", + "conflict": "This rule was updated elsewhere. Please reload.", + "saveCheckNodes": "Save failed. Check the highlighted nodes and edges.", + "ruleEnabled": "Rule enabled", + "ruleDisabled": "Rule disabled", + "leaveTitle": "Unsaved changes", + "leaveDesc": "You have unsaved changes. Leave anyway?", + "leaveConfirm": "Leave", + "deleteNode": "Delete node", + "systemNodeLocked": "System nodes cannot be deleted", + "deleteEdge": "Delete edge", + "propsTitle": "Node properties", + "propsDesc": "Configure this processing unit", + "systemNodeNoConfig": "System nodes do not need configuration.", + "displayName": "Display name", + "ips": "IP addresses", + "cidrs": "CIDR ranges", + "ipGroups": "IP groups", + "countries": "Country codes", + "countriesDesc": "{count} countries/regions, showing names and ISO codes", + "regions": "Region codes", + "regionsDesc": "{count} first-level regions. Search by name or code.", + "uaCheck": "UA check", + "enableUaCheck": "Enable UA check", + "enableUaCheckTip": "When enabled, requests without a User-Agent return False", + "block": "Block", + "blockTip": "A match returns false and takes priority over match rules", + "blockBots": "Block common crawler UAs", + "blockBotsTip": "Browser or OS classified as Bot (bot / spider / crawler / slurp, e.g. Googlebot)", + "blockAbnormal": "Block abnormal UAs", + "blockAbnormalTip": "Browser classified as Other or Unknown (excludes search-engine bots; use the switch above for crawlers)", + "blockCustom": "Block custom UAs", + "blockCustomTip": "Returns false if the raw User-Agent matches any regex (Lua pattern syntax)", + "customUaPatterns": "Custom UA regex", + "customUaPatternsTip": "One Lua-pattern regex per line. Any match returns false. Max 32.", + "uaMatch": "UA match", + "matchMode": "Match mode", + "matchModeTip": "Used when both browser and OS selections are set", + "matchOr": "OR", + "matchAnd": "AND", + "browsers": "Browsers", + "os": "Operating systems", + "security": "Security checks", + "securityTip": "Any enabled rule match returns False", + "basicProtection": "Basic protection", + "help": "Help", + "oneValuePerLine": "One value per line", + "selectedCount": "{count} selected", + "pleaseSelect": "Select", + "createLabel": "New {label}", + "searchNameOrCode": "Search name or code", + "addCode": "Add code", + "searchOptions": "Search {count} options by name or code", + "noMatchingOptions": "No matching options", + "algorithm": "Algorithm", + "algorithmFast": "Fast", + "algorithmSlow": "Robust", + "difficulty": "Difficulty", + "sessionTtl": "Session TTL (seconds)", + "challengeTtl": "Challenge TTL (seconds)", + "statusCode": "HTTP status code", + "responseBody": "HTML response body", + "bytes": "bytes", + "securityRules": { + "path_traversal": { + "label": "Path traversal", + "tip": "Detect ../ and encoded variants in Path / Query / Body" + }, + "file_inclusion": { + "label": "File inclusion (LFI/RFI)", + "tip": "Detect php://, file://, /etc/passwd, and similar in Path / Query / Body" + }, + "sql_injection": { + "label": "SQL injection", + "tip": "Detect SQL injection patterns in Query / Body / Header / Cookie" + }, + "command_injection": { + "label": "Command injection", + "tip": "Detect OS command injection patterns in Query / Body / Header" + }, + "xss": { + "label": "XSS", + "tip": "Detect reflected XSS patterns in Query / Body / Header" + }, + "ssrf": { + "label": "SSRF", + "tip": "Detect private addresses and dangerous protocols in Query / Body" + }, + "malicious_upload": { + "label": "Malicious upload", + "tip": "Detect dangerous multipart filenames and extensions" + }, + "xxe": { + "label": "XXE", + "tip": "Detect external-entity patterns in XML bodies" + }, + "crlf_injection": { + "label": "CRLF injection", + "tip": "Detect newline injection in Header / Query / Body" + } + } + } + }, + "ipGroups": { + "title": "IP groups", + "create": "New IP group", + "listTitle": "IP groups", + "listDesc": "Maintain manual, automatic, and subscription IP sets that WAF allow/deny lists can reference.", + "refresh": "Refresh", + "loading": "Loading IP groups...", + "empty": "No IP groups yet. Create one first.", + "updated": "IP group updated", + "created": "IP group created", + "deleted": "IP group deleted", + "synced": "Sync completed", + "ipRemoved": "IP removed", + "testHit": "Rule test finished. {count} IPs matched.", + "testMiss": "Rule test finished. No IPs matched.", + "deleteTitle": "Delete IP group", + "deleteDesc": "Delete IP group “{name}”? This cannot be undone.", + "deleting": "Deleting...", + "confirmDelete": "Delete", + "operationFailed": "Operation failed", + "types": { + "manual": "Manual", + "automatic": "Automatic", + "subscription": "Subscription" + }, + "enabled": "Enabled", + "disabled": "Disabled", + "noSyncRecord": "No sync history", + "columns": { + "name": "Name", + "type": "Type", + "status": "Status", + "ipCount": "IPs", + "refCount": "References", + "syncStatus": "Sync status", + "updatedAt": "Updated", + "actions": "Actions" + }, + "actions": { + "view": "View", + "edit": "Edit", + "test": "Test rule", + "runNow": "Run now", + "syncNow": "Sync now", + "delete": "Delete" + }, + "ban": { + "permanent": "Permanent", + "expired": "Expired", + "minutesLater": "in {count} min", + "hoursLater": "in {count} hr" + }, + "presets": { + "scan404": "High-frequency 404 scan from one IP", + "directAccess": "Abnormal direct access from one IP" + }, + "dialog": { + "editTitle": "Edit {name}", + "createTitle": "New IP group", + "description": "Maintain manual, automatic, and subscription IP sets that WAF allow/deny lists can reference.", + "name": "IP group name", + "nameRequired": "Enter an IP group name", + "type": "Type", + "enable": "Enable IP group", + "enableDesc": "When disabled, the configuration is kept but not expanded into the WAF runtime list on publish.", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlRequired": "Subscription type requires a URL", + "subscriptionFormat": "Subscription format", + "formatText": "Text list", + "syncInterval": "Sync interval (minutes)", + "syncIntervalDesc": "Minimum 1 minute. Default 1440 minutes.", + "mappingRule": "JSON mapping rule", + "mappingPlaceholder": "Leave empty for the root array", + "autoIntervalDesc": "How often to mine malicious IPs from request logs. Minimum 1 minute, default 1440 minutes.", + "presetRules": "Preset rules", + "autoConfigJson": "Automatic configuration JSON", + "ipList": "IPs / CIDRs", + "ipListSubscriptionHint": "The next subscription sync overwrites this list. You can still save the current contents first.", + "ipListManualHint": "One IP or CIDR per line.", + "saving": "Saving...", + "save": "Save IP group" + }, + "testDialog": { + "title": "Automatic rule test result", + "description": "Replay request logs against the current automatic configuration JSON.", + "testing": "Testing...", + "summary": "Lookback {lookback} · {rules} rules · {matched} IPs matched", + "testedAt": "Tested at: {time}", + "noMatch": "No IPs matched.", + "empty": "No test result yet.", + "close": "Close" + }, + "viewDialog": { + "titleNamed": "View {name}", + "title": "View IP group", + "summary": "{type} · {count} IPs", + "fallbackDesc": "View IPs in this group and remove entries you no longer need.", + "loading": "Loading IP list...", + "noGroup": "No IP group selected.", + "emptyAuto": "This automatic rule has not captured any IPs yet. Click “Run now” to trigger a capture.", + "empty": "This IP group has no IPs.", + "subscriptionHint": "Subscription groups may restore deleted IPs on the next sync.", + "ipAddress": "IP address", + "capturedAt": "Captured at", + "banRemaining": "Ban remaining", + "actions": "Actions", + "deleteIp": "Delete {ip}", + "close": "Close", + "deleteTitle": "Delete IP", + "deleteDesc": "Remove {ip} from IP group “{name}”?", + "deleting": "Deleting...", + "confirmDelete": "Delete" + }, + "autoConfigMustBeObject": "Automatic configuration must be a JSON object." + }, + "rateLimits": { + "title": "Rate limits", + "subtitle": "Inspect edge request pressure and configure default concurrency and bandwidth limits.", + "loadingAuth": "Loading permissions...", + "forbiddenTitle": "Insufficient permissions", + "forbiddenDesc": "Only administrators can access rate-limit settings.", + "tabAnalysis": "Analysis", + "tabConfig": "Configuration", + "loadingPage": "Loading rate limits...", + "loadingPressure": "Loading request pressure...", + "loadFailed": "Load failed", + "emptyTitle": "No data", + "emptyDesc": "There are no access logs in the current time range.", + "topHost": "Highest average RPS host", + "topIp": "Highest average RPS IP", + "avgRate": "Average request rate for {range}", + "range": { + "h24": "24 hours", + "h72": "3 days" + }, + "chart": { + "title": "Request pressure", + "empty": "No request-pressure data", + "requestRate": "Request rate", + "uniqueVisitors": "Unique visitors", + "visitorsPerBucket": "Visitors / bucket" + }, + "config": { + "saved": "Rate-limit settings saved", + "saveFailed": "Save failed", + "loading": "Loading rate-limit settings...", + "preview": "View config preview", + "title": "Global default rate limits", + "description": "0 or empty means disabled by default. Sites without their own settings inherit these values. Publish a release to apply changes.", + "save": "Save", + "connPerServer": "Default concurrency (per site)", + "connPerServerHint": "Default maximum concurrent connections per site", + "connPerIp": "Default concurrency per IP", + "connPerIpHint": "Default maximum concurrent connections per IP", + "limitRate": "Default bandwidth limit", + "limitRateHint": "Default per-request bandwidth, e.g. 512k or 1m", + "reqPerIp": "Default request-rate limit per IP", + "reqPerIpHint": "Default request rate per IP, e.g. 10r/s or 100r/m. Leave empty to disable.", + "invalidConn": "Enter a non-negative integer for concurrency, or leave empty to disable", + "invalidRate": "Invalid rate format. Use 512k, 1m, a number, or leave empty to disable", + "invalidReq": "Invalid request-rate format. Use values like 10r/s or 100r/m, or leave empty to disable" + } + }, + "pages": { + "title": "Pages", + "subtitle": "Host static sites at the edge. Upload a static package and select Pages as the upstream in proxy rules.", + "refresh": "Refresh", + "create": "New project", + "loadFailed": "Load failed", + "emptyTitle": "No Pages projects yet", + "emptyDesc": "Create a project, then upload a static deployment package.", + "enabled": "Enabled", + "disabled": "Disabled", + "spaFallback": "SPA fallback", + "strict404": "Strict 404", + "currentActive": "Currently active", + "none": "None", + "activatedAt": "Activated: ", + "notActivated": "Not activated", + "viewDetail": "View details", + "saveFailed": "Save failed", + "updated": "Project updated", + "created": "Project created", + "deleted": "Project deleted", + "deleteFailed": "Delete failed", + "editTitle": "Edit Pages project", + "createTitle": "New Pages project", + "editorDesc": "Configure static hosting settings. After uploading a package, select Pages as the upstream in proxy rules.", + "settingsDesc": "Configure static hosting settings. Saving syncs the project details and proxy references.", + "reset": "Reset", + "saving": "Saving...", + "saveChanges": "Save changes", + "createProject": "Create project", + "form": { + "name": "Project name", + "nameRequired": "Enter a project name", + "slug": "Project slug", + "slugPlaceholder": "Leave empty to auto-generate", + "entryFile": "Entry file", + "entryRequired": "Enter an entry file", + "rootDir": "Root directory", + "optional": "Optional", + "spaFallback": "SPA fallback", + "spaFallbackDesc": "Fall back to the specified path when no static file matches", + "fallbackPath": "Fallback path", + "fallbackPathPrefix": "Fallback path must start with /", + "apiProxy": "API reverse proxy", + "apiProxyDesc": "Attach API proxy rules to the static site", + "matchPath": "Match path", + "matchPathPrefix": "Match path must start with /", + "backend": "Backend URL", + "backendPrefix": "Backend URL must start with http:// or https://", + "rewrite": "Rewrite rule" + }, + "detail": { + "invalidId": "A valid Pages project ID is required.", + "loading": "Loading project details...", + "loadFailed": "Failed to load project details", + "back": "Back to list", + "notFound": "This Pages project does not exist or has been deleted.", + "deploymentCount": "{count} deployments", + "tabDeployments": "Deployments", + "tabSettings": "Settings" + }, + "danger": { + "title": "Danger zone", + "deleteProject": "Delete project", + "deleteHint": "Permanently delete {name} ({slug}) and all of its deployments.", + "confirmTitle": "Delete Pages project", + "confirmDesc": "Delete project {name}? This cannot be undone.", + "confirm": "Delete" + }, + "upload": { + "selectFile": "Select a deployment package", + "success": "Deployment package uploaded", + "failed": "Upload failed", + "unsupported": "Only zip, tar.gz, tar.xz, tar.bz2, tar, and 7z packages are supported", + "title": "Upload deployment package", + "description": "Upload an immutable static archive. You can activate it from deployment history afterward.", + "localPackage": "Local package", + "dropHint": "Drop a package here, or click to choose a file", + "selected": "Selected {name} ({size})", + "chooseArchive": "Choose a supported archive.", + "entry": "Deployment entry", + "entryHint": "The entry comes from project settings. Uploading a package does not overwrite it.", + "processing": "Processing on server", + "progress": "Upload progress", + "pleaseWait": "Please wait", + "uploading": "Uploading...", + "submit": "Upload and create deployment" + }, + "files": { + "loadFailed": "Failed to load file list", + "empty": "No file records", + "path": "Path", + "size": "Size" + }, + "history": { + "source": { + "manual_upload": "Local upload", + "manual_url": "URL import" + }, + "trigger": { + "manual_upload": "Manual upload", + "manual_url": "Manual import", + "manual_sync": "Manual sync", + "scheduled_auto_update": "Scheduled update" + }, + "fileMeta": "{checksum} · {count} files · ", + "createdAt": "Created {time}", + "activatedAt": " · Activated {time}", + "collapseFiles": "Collapse file list", + "expandFiles": "Expand file list", + "deploymentNumber": "Deployment #{number}", + "activate": "Activate", + "delete": "Delete", + "activated": "Historical deployment activated. Auto-update (if enabled) has been turned off.", + "activateFailed": "Activation failed", + "deleted": "Deployment deleted", + "deleteFailed": "Delete failed", + "activateTitle": "Activate historical deployment", + "deleteTitle": "Delete deployment", + "activateDesc": "Activating another historical deployment stops the current source task. If auto-update is enabled, it will be turned off.", + "deleteDesc": "Delete deployment #{number}? This cannot be undone.", + "confirm": "Confirm", + "loading": "Loading deployment history...", + "loadFailed": "Failed to load deployment history", + "productionHint": "This is the live production deployment. Changing the active deployment takes effect immediately.", + "manualUpload": "Manual upload", + "emptyProductionTitle": "No production deployment", + "emptyProductionDesc": "Upload a local package, or configure a Remote URL / GitHub Release source and sync.", + "historyHint": "Deployments are immutable. Source info is a snapshot from creation time. You can activate or delete historical deployments.", + "emptyTitle": "No deployments", + "emptyDesc": "Upload a local package, or configure a Remote URL / GitHub Release source and sync." + }, + "source": { + "status": { + "idle": "Idle", + "checking": "Checking", + "update_available": "Update available", + "syncing": "Syncing", + "failed": "Last sync failed", + "attention": "Needs confirmation" + }, + "check": "Check", + "syncPublish": "Sync and publish", + "missingExecutionId": "Missing task execution ID", + "actionDone": "Source {action} completed", + "actionFailed": "Source {action} failed", + "checkQueued": "Check task queued", + "checkQueueFailed": "Failed to queue check task", + "syncQueued": "Sync task queued", + "syncQueueFailed": "Failed to queue sync task", + "title": "Deployment source", + "loadingDesc": "Loading source configuration...", + "independentDesc": "Source configuration is independent of deployment history.", + "loadFailed": "Failed to load deployment source", + "configDesc": "Configure a remote source and sync to publish. Results appear in deployment history.", + "manualBadge": "Manual deploy", + "localPackage": "Local package", + "noRemote": "No persistent remote source. Upload a package, then activate it from deployment history.", + "submitFailed": "Failed to queue source task", + "statusFailed": "Failed to read task status", + "waitStopped": "Automatic waiting stopped. The task may still be running in the background.", + "refreshStatus": "Refresh task status", + "configure": "Configure", + "checkUpdate": "Check for updates", + "refresh": "Refresh", + "confirmTitle": "Publish the current GitHub revision", + "confirmLead": "This publishes the exact revision currently shown on the card:", + "revisionUnavailable": "The current revision is no longer available. Refresh and try again.", + "confirmPublish": "Confirm and publish", + "updated": "Deployment source updated", + "updateFailed": "Failed to update deployment source", + "switchedManual": "Switched back to manual deploy", + "switchFailed": "Switch failed", + "urlRequired": "Enter a Remote URL", + "urlInvalid": "Enter a valid HTTP(S) URL", + "githubUrlInvalid": "Enter a public repository URL in https://github.com/'{owner}'/'{repo}' format", + "tagInvalid": "Release tag must be a valid Git ref (1–255 bytes; / # & = are allowed)", + "assetInvalid": "Asset name must be 1–255 bytes and cannot be a path or contain control, newline, or bidi characters", + "intervalInvalid": "Check interval must be an integer between 5 and 1440 minutes", + "useManual": "Use manual deploy", + "saveRemote": "Save Remote source", + "saveGithub": "Save GitHub source", + "settingsTitle": "Deployment source", + "settingsDesc": "Choose a deployment source.", + "mode": "Source type", + "manual": "Manual deploy", + "manualHint": "Keep existing deployments and the current production version. Create new deployments with “Upload package”.", + "urlHint": "Enter an HTTP(S) URL that downloads a deployment package directly.", + "allowInsecure": "Allow insecure connections", + "allowInsecureDesc": "Public and private addresses are allowed by default. Enabling this skips TLS certificate verification for self-signed or private CAs.", + "switchManualTitle": "Switch back to manual deploy", + "switchManualDesc": "The current source configuration will be deleted. Existing deployments and the current production version are kept.", + "githubUrl": "GitHub repository URL", + "githubUrlHint": "Only public github.com repositories are supported.", + "releaseSelect": "Release selection", + "latestRelease": "Latest release", + "pinnedTag": "Pinned tag", + "pinnedTagHint": "Check and sync this exact tag.", + "autoUpdate": "Auto-update", + "autoUpdateHint": "Automatically sync and publish when a new release is found.", + "checkInterval": "Check interval (minutes)", + "checkIntervalHint": "Can be set to 5–1440 minutes.", + "assetName": "Release asset filename", + "noRecord": "No record yet", + "address": "URL", + "verifyCert": "Verify certificate", + "lastSync": "Last sync", + "neverSynced": "Not synced yet", + "applied": "Applied", + "latestReleaseLabel": "Latest release", + "pinnedTagLabel": "Pinned tag · {tag}", + "tagMissing": "Not provided", + "assetChangedTitle": "Release asset changed and needs confirmation", + "assetChangedDesc": "The remote revision does not match the published content. Verify the version and assets, then confirm publishing this exact revision.", + "autoUpdateOn": "On", + "autoUpdateOff": "Off", + "intervalMinutes": "{count} minutes", + "nextCheck": "Next check", + "waitingSchedule": "Waiting for schedule", + "lastCheck": "Last check", + "neverChecked": "Not checked yet", + "remote": "Remote" + } + }, + "accessLogs": { + "title": "Access logs", + "subtitle": "View traffic overview, IP details, and request logs.", + "refresh": "Refresh", + "cleanup": "Clean up logs", + "tabOverview": "Overview", + "tabIps": "IPs", + "tabList": "Logs", + "cleaned": "Cleaned up {count} logs", + "cleanupFailed": "Cleanup failed", + "loadingTitle": "Loading access logs", + "loadingDesc": "Preparing the page...", + "loadFailed": "Load failed", + "range": { + "h24": "24 hours", + "h168": "7 days", + "h360": "15 days", + "h720": "30 days" + }, + "rangeHint": { + "hours24": "Last 24 hours", + "days": "Last {count} days", + "hours": "Last {count} hours" + }, + "sort": { + "logged_at:desc": "Newest first", + "logged_at:asc": "Oldest first", + "status_code:desc": "Status high to low", + "status_code:asc": "Status low to high", + "remote_addr:asc": "IP A–Z", + "remote_addr:desc": "IP Z–A", + "total_requests:desc": "Requests high to low", + "total_requests:asc": "Requests low to high", + "request_length:desc": "Inbound high to low", + "request_length:asc": "Inbound low to high", + "bytes_sent:desc": "Outbound high to low", + "bytes_sent:asc": "Outbound low to high", + "success_ratio:desc": "2xx ratio high to low", + "success_ratio:asc": "2xx ratio low to high", + "last_seen_at:desc": "Last seen newest first", + "last_seen_at:asc": "Last seen oldest first" + }, + "cache": { + "hit": "Hit", + "origin": "Origin", + "uncached": "Uncached" + }, + "filters": { + "pickTime": "Pick a time", + "remoteAddr": "Source IP", + "remoteAddrPlaceholder": "Search by IP", + "host": "Host", + "hostPlaceholder": "Search by host", + "statusCode": "Status code", + "statusCodePlaceholder": "Enter a status code, e.g. 404", + "more": "More filters", + "nodeId": "Node ID", + "nodeIdPlaceholder": "Search by node_id", + "path": "Path", + "pathPlaceholder": "Search by path", + "timeRange": "Time range", + "pageSize": "Per page", + "pageSizeItem": "{count} items", + "apply": "Filter", + "clear": "Clear" + }, + "cleanupDialog": { + "invalidDays": "Retention days must be greater than 0", + "title": "Clean up access logs", + "description": "Delete access logs older than the retention period. This cannot be undone.", + "policy": "Retention policy", + "keepDays": "Keep last {days} days", + "custom": "Custom days", + "customDays": "Custom retention days", + "cleaning": "Cleaning...", + "confirm": "Clean up" + }, + "detail": { + "title": "Access log details", + "description": "All fields for this request. Use the IPs tab for IP analysis.", + "logId": "Log ID", + "requestTime": "Request time", + "storedAt": "Stored at", + "node": "Node", + "nodeId": "Node ID", + "clientIp": "Client IP", + "region": "Region", + "host": "Host", + "status": "Status", + "cache": "Cache", + "bytesSent": "Outbound", + "requestLength": "Inbound", + "duration": "Duration", + "path": "Path", + "page": "Page {page}", + "prev": "Previous", + "next": "Next", + "listTitle": "Logs", + "empty": "No access logs", + "time": "Time", + "view": "View details" + }, + "ip": { + "needRange": "Enter a start and end time", + "invalidTime": "Invalid time format", + "endAfterStart": "End time must be after start time", + "maxRange": "Time range cannot exceed 30 days", + "page": "Page {page}", + "total": " · {count} IPs", + "prev": "Previous", + "next": "Next", + "customRange": "Custom range", + "customPending": "Custom (not applied)", + "timeRange": "Time range", + "current": "Current: {range}", + "custom": "Custom", + "start": "Start", + "end": "End", + "apply": "Apply", + "applyHint": "Click Apply after changing the time. The detail window matches the list range.", + "sort": "Sort", + "pageSize": "Per page", + "title": "IPs", + "empty": "No IP data", + "region": "Region", + "requests": "Requests", + "successRatio": "2xx ratio", + "inbound": "Inbound", + "outbound": "Outbound", + "lastSeen": "Last seen", + "view": "View IP details", + "dialogTitle": "IP details", + "dialogDesc": ". View this IP’s traffic trend, distribution, and WAF IP-group actions." + }, + "analysis": { + "emptySeries": "No {title} data", + "selectGroup": "Select an IP group to join", + "added": "Added {ip} to IP group “{name}”", + "addFailed": "Failed to add to IP group", + "removed": "Removed {ip} from IP group “{name}”", + "removeFailed": "Remove failed", + "joinTitle": "Add IP to IP group", + "targetIp": "Target IP:", + "loadingGroups": "Loading IP groups", + "loadGroupsFailed": "Failed to load IP groups", + "alreadyIn": "This IP is already in these IP groups", + "alreadyInHint": "You can remove it, or add it to another IP group.", + "entryCount": "{type} · {count} entries", + "delete": "Delete", + "cannotDelete": "Cannot delete manually", + "notInAny": "This IP is not in any IP group yet.", + "addToOther": "Add to another IP group", + "noWritable": "No writable manual IP groups (or they already contain this IP).", + "selectManual": "Select a manual IP group", + "close": "Close", + "processing": "Working...", + "joinSelected": "Add to selected IP group", + "recentHours": "Last {hours} hours", + "invalidIp": "No valid IP.", + "addToGroup": "Add IP to IP group", + "loading": "Loading IP analysis", + "loadFailed": "Failed to load IP analysis", + "totalRequests": "Total requests", + "errors": "Errors", + "bandwidth": "Bandwidth served", + "received": "Data received", + "uniqueHosts": "Unique hosts", + "uniquePaths": "Unique paths", + "trend": "IP request trend", + "trendMeta": "{ip} · {range} · {minutes}-minute buckets", + "refresh": "Refresh", + "trendFailed": "Failed to load trend", + "trendEmpty": "This IP has no requests in {range}." + }, + "overview": { + "loadingTitle": "Loading traffic overview", + "loadingDesc": "Aggregating requests, visits, and bandwidth trends...", + "loadFailed": "Failed to load traffic overview", + "emptyTitle": "No overview data", + "emptyDesc": "There are no traffic stats in the current time range.", + "uniqueVisitors": "{hint} · unique visitors", + "served": "{hint} · data served", + "requestsHint": "Watch for unusual spikes or drops in request volume.", + "requests": "Requests", + "deviceDesc": "Request share by device type.", + "deviceEmpty": "No device-type data", + "statusDesc": "Request share by HTTP status code.", + "statusEmpty": "No status-code data", + "topPathsDesc": "Most requested paths.", + "topHostsDesc": "Hosts with the most traffic.", + "topIpsDesc": "Source IPs with the most requests.", + "browsersDesc": "Requests aggregated by browser.", + "osDesc": "Requests aggregated by operating system.", + "uaDesc": "Requests ranked by raw User-Agent." + }, + "toolbar": { + "hostsFiltered": "{count} hosts filtered", + "filterByHost": "Filter by host", + "filterHosts": "Filter hosts", + "clear": "Clear", + "searchPlaceholder": "Search zone or host", + "loadingHosts": "Loading hosts…", + "loadHostsFailed": "Failed to load hosts", + "retry": "Retry", + "noRegistered": "No registered hosts", + "noMatch": "No matching hosts", + "selectZone": "Select zone {name}", + "selectHost": "Select host {name}", + "selectedCount": "{count} hosts selected" + } + }, + "cloudflare": { + "title": "Cloudflare", + "connectionSettings": "Connection settings", + "addGroup": "New group", + "loadingOverview": "Loading Cloudflare overview...", + "notReadyTitle": "Cloudflare connection is not ready", + "notReadyDesc": "Import an existing Cloudflare DNS account, or enter a standalone API token and test the connection.", + "configure": "Configure connection", + "groupsTitle": "Pointing groups", + "groupsDesc": "Manage the nodes and domain members behind Cloudflare A records.", + "loadingGroups": "Loading pointing groups...", + "emptyTitle": "No pointing groups", + "emptyDesc": "After creating a group, you can sync domain members to selected edge nodes.", + "enabled": "Enabled", + "disabled": "Disabled", + "memberSummary": "{count} members · new members default orange-cloud {proxy}", + "proxyOn": "on", + "proxyOff": "off", + "manage": "Manage", + "sync": "Sync", + "delete": "Delete", + "created": "Pointing group created", + "syncQueued": "Group sync queued", + "deleted": "Pointing group and remote records deleted", + "deleteTitle": "Delete pointing group", + "deleteDesc": "This deletes all members of {name} and the remote A records managed by this module. This cannot be undone.", + "confirmDelete": "Delete", + "loadingDetail": "Loading group details...", + "groupUpdated": "Group settings updated", + "memberAddedOne": "Domain added and queued for sync", + "memberAddedMany": "Added {count} domains and queued them for sync", + "memberAddFailed": "Failed to add {count} domains", + "memberPartial": "Partial success: {succeeded} added, {failed} failed", + "proxyUpdated": "Orange-cloud setting updated and queued for sync", + "memberSyncQueued": "Member sync queued", + "memberDeleted": "Member and remote A record deleted", + "back": "Back to list", + "refresh": "Refresh", + "edit": "Edit", + "addDomain": "Add domain", + "currentPoint": "Current target", + "activeNode": "Active node {name} · {ip}", + "syncEnabled": "Sync enabled", + "syncDisabled": "Sync disabled", + "primaryNode": "Primary {name}", + "backupNode": "Backup {name}", + "backupUnset": "Not set", + "members": "Domain members", + "membersDesc": "Per-member orange-cloud is the only source of truth during sync.", + "noMembers": "No domain members yet.", + "columns": { + "domain": "Domain", + "desiredIp": "Desired IP", + "status": "Status", + "proxied": "Orange cloud", + "actions": "Actions" + }, + "pendingSync": "Pending sync", + "remove": "Remove", + "groupDialog": { + "editTitle": "Edit pointing group", + "createTitle": "New pointing group", + "description": "Phase 1 uses the primary node as the active node. Backup nodes are saved only and do not fail over automatically.", + "name": "Group name", + "primary": "Primary node", + "primaryPlaceholder": "Select an edge node with IPv4", + "noIp": "No IP configured", + "backup": "Backup node", + "none": "None", + "defaultProxied": "Enable orange cloud for new members by default", + "enableSync": "Enable sync", + "save": "Save" + }, + "memberDialog": { + "title": "Add domain members", + "description": "Search and select in bulk. Joining creates or takes over the unique same-name A record. Multiple same-name A records are rejected.", + "zoneDomain": "Zone domain", + "searchPlaceholder": "Search domains or zones…", + "selected": "{count} selected", + "visible": "{count} visible", + "unselectAll": "Clear visible", + "selectVisible": "Select visible", + "clear": "Clear", + "noAvailable": "No available domains (already in a group, or not added in website management).", + "noMatch": "No matching domains", + "groupByZone": "Group by zone", + "selectZone": "Select zone {name}", + "zone": "Zone", + "proxied": "Enable orange-cloud proxy", + "submitCount": "Add and sync ({count})", + "submit": "Add and sync" + }, + "tasks": { + "pending": "Pending", + "running": "Running", + "succeeded": "Succeeded", + "failed": "Failed", + "system": "System", + "manual": "Manual", + "retry": "Retry", + "schedule": "Scheduled", + "retried": "Sync task re-queued", + "newTaskId": "New task ID: {id}", + "retryFailed": "Failed to retry task", + "unknownError": "Unknown error", + "title": "Sync tasks", + "description": "Shows Cloudflare domain sync (sync_member) and group sync (sync_group) executions only.", + "statusPlaceholder": "Status", + "allStatus": "All statuses", + "refresh": "Refresh", + "loadFailed": "Failed to load sync tasks", + "loading": "Loading sync tasks...", + "emptyTitle": "No sync tasks", + "emptyDesc": "After adding domains or syncing a group, Cloudflare domain/group sync executions appear here.", + "columns": { + "task": "Task", + "status": "Status", + "trigger": "Trigger", + "duration": "Duration", + "result": "Result / error", + "createdAt": "Created" + }, + "pageSummary": "{total} total, page {page}/{pages}", + "prev": "Previous", + "next": "Next", + "detailTitle": "Sync task details", + "defaultName": "Cloudflare sync", + "loadingDetail": "Loading task details...", + "status": "Status", + "trigger": "Trigger", + "retries": "Retries", + "duration": "Duration", + "taskId": "Task ID", + "createdAt": "Created", + "finishedAt": "Finished", + "result": "Result", + "error": "Error", + "logs": "Logs", + "close": "Close" + }, + "settings": { + "saved": "Cloudflare connection saved", + "verified": "Cloudflare connection verified", + "cleared": "Cloudflare connection cleared", + "back": "Back to list", + "title": "Cloudflare connection", + "sourceTitle": "Credential source", + "sourceDesc": "The token should have Zone:Read and DNS:Edit. Tokens are never echoed in the API or UI.", + "source": "Connection source", + "importDns": "Import existing DNS account", + "standalone": "Standalone API token", + "dnsAccount": "Cloudflare DNS account", + "selectAccount": "Select a DNS account", + "dnsHint": "Only cloudflare-type DNS accounts are shown. Add one in", + "dnsLink": "DNS accounts", + "dnsHintAfter": ".", + "tokenKeep": "Leave empty to keep the existing token; saving replaces it", + "tokenEnter": "Enter a Cloudflare API token", + "save": "Save settings", + "test": "Test connection", + "clear": "Clear connection", + "statusTitle": "Current status", + "ready": "Token verified. Cloudflare pointing sync can run.", + "notReady": "After saving, you still need to test the connection. Sync is rejected until it is verified." + } + }, + "openflareOps": { + "saved": "OpenFlare operations settings saved", + "saveFailed": "Save failed", + "tokenRotated": "Discovery token regenerated", + "tokenRotateFailed": "Failed to rotate token", + "kumaSynced": "Uptime Kuma sync ran", + "syncFailed": "Sync failed", + "invalidParams": "Validation failed", + "loading": "Loading OpenFlare operations settings...", + "loadFailed": "Load failed", + "save": "Save", + "agent": { + "title": "Agent runtime", + "description": "Heartbeat interval and offline threshold sync to nodes on the next heartbeat.", + "heartbeat": "Heartbeat interval ({duration})", + "offline": "Offline threshold ({duration})", + "wsUpgrade": "Enable WebSocket upgrade", + "wsUpgradeDesc": "After a successful HTTP heartbeat, try upgrading to WebSocket so config publishes can notify immediately.", + "updateRepo": "Agent update repository" + }, + "geo": { + "title": "IP geolocation", + "description": "Controls the GeoIP source for node maps and similar views. Visitor access-log geolocation always uses MaxMind mmdb.", + "mode": "Lookup method", + "disabled": "Off", + "testIp": "Test IP", + "querying": "Looking up...", + "query": "Look up", + "country": "Country / region" + }, + "pages": { + "title": "Pages static hosting", + "description": "Configure the upload size limit and how many historical deployments each project keeps.", + "maxSize": "Package size limit (MiB)", + "history": "Historical deployments to keep (0 = unlimited)", + "historyHint": "Each project keeps at most N deployments: the active one is always kept, then the rest fill newest-first. Extra inactive deployments are cleaned up after a successful upload. zip, tar.gz, tar.xz, tar.bz2, tar, and 7z are supported." + }, + "discovery": { + "title": "Discovery token and deploy", + "description": "New nodes use the discovery token on first join. Rotate it from node management or the action below.", + "manageNodes": "Node management", + "rotate": "Rotate token", + "tokenReadonly": "Discovery token (read-only)", + "loading": "Loading...", + "notGenerated": "Not generated", + "command": "One-line deploy command", + "commandHint": "Enter a reachable Server URL and fetch a token first.", + "copied": "Command copied", + "copy": "Copy command" + }, + "kuma": { + "title": "Uptime Kuma integration", + "description": "Diff-sync reverse-proxy sites to an Uptime Kuma instance.", + "syncNow": "Sync now", + "enable": "Enable Uptime Kuma", + "url": "Uptime Kuma URL", + "username": "Username", + "password": "Password", + "passwordKeep": "Leave empty to keep the current password", + "syncInterval": "Sync interval (minutes)", + "scope": "Monitor scope", + "allSites": "All sites", + "selectedSites": "Selected sites", + "selectedLabel": "Selected sites", + "pickSites": "Choose monitored sites", + "noneSelected": "No sites selected", + "interval": "Check interval (seconds)", + "retry": "Retries", + "retryInterval": "Retry interval (seconds)", + "timeout": "Request timeout (seconds)" + }, + "duration": { + "minutes": "{count} min", + "seconds": "{count} sec" + }, + "errors": { + "heartbeatMin": "Heartbeat interval cannot be less than 1000 milliseconds.", + "offlineMin": "Offline threshold cannot be less than 10000 milliseconds.", + "offlineRatio": "Offline threshold should be at least 3× the heartbeat interval.", + "kumaUrl": "Enter an Uptime Kuma URL.", + "kumaUser": "Enter an Uptime Kuma username.", + "syncInterval": "Sync interval must be a positive integer.", + "interval": "Heartbeat interval must be a positive integer.", + "retry": "Retry count must be a non-negative integer.", + "retryInterval": "Heartbeat retry interval must be a positive integer.", + "timeout": "Request timeout must be a positive integer.", + "pagesSize": "Pages package size limit must be between 1 and 2048 MiB.", + "pagesHistory": "Pages history count must be an integer ≥ 0 (0 means unlimited)." + }, + "status": { + "title": "System status", + "loadFailed": "Failed to load system status", + "unknownError": "An unknown error occurred", + "autoRefresh": "Auto-refresh", + "refresh": "Refresh", + "overview": "Service overview", + "overviewDesc": "Basic service health metrics", + "uptime": "Uptime", + "goroutines": "Goroutines", + "heapObjects": "Heap objects", + "memory": "Memory", + "memoryDesc": "Primary memory usage", + "alloc": "Current allocation", + "totalAlloc": "Total allocated", + "sys": "System memory", + "nextGc": "Next GC target", + "usageRatio": "Current usage (Alloc / Sys)", + "heapStack": "Heap / stack", + "heapStackDesc": "Runtime heap and stack details", + "heapAlloc": "Heap allocated", + "heapSys": "Heap system", + "heapIdle": "Heap idle", + "heapInuse": "Heap in use", + "heapReleased": "Heap released", + "stackInuse": "Stack in use", + "stackSys": "Stack system", + "structs": "Internal structures", + "structsDesc": "Runtime management structure overhead", + "mspanInuse": "MSpan in use", + "mspanSys": "MSpan system", + "mcacheInuse": "MCache in use", + "mcacheSys": "MCache system", + "buckHashSys": "Profiling hash table", + "gcSys": "GC metadata", + "otherSys": "Other system memory", + "gc": "GC and allocations", + "gcDesc": "GC history and allocation counts", + "numGc": "GC runs", + "lastGc": "Time since last GC", + "pauseTotal": "Total GC pause", + "lastPause": "Last GC pause", + "mallocs": "Allocations", + "frees": "Frees", + "lookups": "Pointer lookups" + }, + "siteModal": { + "title": "Choose monitored sites", + "description": "Select reverse-proxy sites to sync to Uptime Kuma. Search by site name or domain.", + "search": "Search sites", + "searchPlaceholder": "Search by name or domain...", + "selectFiltered": "Select filtered", + "clearFiltered": "Clear filtered", + "loading": "Loading sites...", + "loadFailed": "Failed to load sites", + "noMatch": "No matching sites", + "select": "Select", + "siteName": "Site name", + "primaryDomain": "Primary domain", + "selectedCount": "{count} monitored sites selected", + "save": "Save selection" + } + } +} diff --git a/frontend/messages/fragments/security.zh-CN.json b/frontend/messages/fragments/security.zh-CN.json new file mode 100644 index 00000000..f2baa1e0 --- /dev/null +++ b/frontend/messages/fragments/security.zh-CN.json @@ -0,0 +1,1080 @@ +{ + "waf": { + "title": "WAF", + "createRule": "新建规则", + "ruleGroups": "规则组", + "ruleGroupsDesc": "使用可视化流程编排 WAF 防护规则;全局规则始终应用到所有网站。", + "refresh": "刷新", + "loading": "加载 WAF 规则组中...", + "empty": "暂无规则组,系统通常会自动创建全局规则组。", + "created": "规则已创建", + "groupDeleted": "规则组已删除", + "deleteTitle": "确认删除规则组", + "deleteDesc": "确认删除规则组「{name}」吗?删除后无法恢复。", + "deleting": "删除中...", + "confirmDelete": "确认删除", + "operationFailed": "操作失败", + "autoConfigMustBeObject": "自动配置必须是 JSON 对象。", + "autoConfigInvalid": "自动配置格式错误", + "saveFailed": "保存失败", + "enabled": "已启用", + "disabled": "已停用", + "enable": "启用", + "disable": "停用", + "global": "全局", + "custom": "自定义", + "allSites": "全部网站", + "siteCount": "{count} 个网站", + "columns": { + "name": "名称", + "type": "类型", + "status": "状态", + "nodeCount": "节点数", + "scope": "应用范围", + "updatedAt": "更新时间", + "actions": "操作" + }, + "actions": { + "compose": "编排", + "delete": "删除" + }, + "createDialog": { + "title": "新建 WAF 规则", + "description": "创建后将进入编排页面,通过处理单元配置规则执行流程。", + "name": "规则名称", + "namePlaceholder": "例如:入口防护", + "submit": "创建并编排" + }, + "editor": { + "missingId": "缺少有效的规则 ID。", + "loadFailed": "规则加载失败,请重试。", + "reload": "重新加载", + "back": "返回", + "graphValid": "图校验通过", + "issueCount": "{count} 个问题", + "unsaved": "未保存", + "enableRule": "启用规则", + "format": "格式化", + "formatTitle": "自动整理节点与连线布局", + "saving": "保存中...", + "save": "保存", + "saved": "规则图已保存", + "conflict": "规则已在其他页面更新,请重新加载", + "saveCheckNodes": "保存失败,请检查标记的节点和连线", + "ruleEnabled": "规则已启用", + "ruleDisabled": "规则已停用", + "leaveTitle": "未保存的更改", + "leaveDesc": "存在未保存的更改,确定离开吗?", + "leaveConfirm": "确定离开", + "deleteNode": "删除节点", + "systemNodeLocked": "系统节点不可删除", + "deleteEdge": "删除连线", + "propsTitle": "节点属性", + "propsDesc": "配置当前处理单元", + "systemNodeNoConfig": "系统节点无需配置。", + "displayName": "显示名称", + "ips": "IP 地址", + "cidrs": "CIDR 网段", + "ipGroups": "IP 组", + "countries": "国家代码", + "countriesDesc": "共 {count} 个国家/地区,显示国家名与 ISO 代码", + "regions": "地区代码", + "regionsDesc": "共 {count} 个一级行政区,输入名称或代码搜索", + "uaCheck": "UA 检查", + "enableUaCheck": "开启 UA 检查", + "enableUaCheckTip": "开启后如果请求头不携带 UA 将返回 False", + "block": "屏蔽", + "blockTip": "命中返回 false,优先级高于匹配", + "blockBots": "屏蔽常见爬虫 UA", + "blockBotsTip": "浏览器或操作系统分类为 Bot(含 bot / spider / crawler / slurp 等特征,如 Googlebot)", + "blockAbnormal": "屏蔽非正常 UA", + "blockAbnormalTip": "浏览器分类为 Other 或 Unknown(不含搜索引擎等爬虫 Bot,爬虫请用上方开关)", + "blockCustom": "屏蔽自定义 UA", + "blockCustomTip": "原始 User-Agent 命中任一条正则时返回 false(Lua 模式语法)", + "customUaPatterns": "自定义 UA 正则", + "customUaPatternsTip": "每行一条 Lua 模式正则,命中任一条即 false;最多 32 条", + "uaMatch": "UA 匹配", + "matchMode": "匹配模式", + "matchModeTip": "浏览器与操作系统两侧都有选择时生效", + "matchOr": "或(OR)", + "matchAnd": "且(AND)", + "browsers": "浏览器", + "os": "操作系统", + "security": "安全防护", + "securityTip": "命中任意已启用规则返回 False", + "basicProtection": "基础防护", + "help": "说明", + "oneValuePerLine": "每行一个值", + "selectedCount": "已选择 {count} 项", + "pleaseSelect": "请选择", + "createLabel": "新建{label}", + "searchNameOrCode": "搜索名称或代码", + "addCode": "添加代码", + "searchOptions": "输入名称或代码搜索 {count} 个选项", + "noMatchingOptions": "没有匹配的选项", + "algorithm": "算法", + "algorithmFast": "快速", + "algorithmSlow": "稳健", + "difficulty": "难度", + "sessionTtl": "会话 TTL(秒)", + "challengeTtl": "挑战 TTL(秒)", + "statusCode": "HTTP 状态码", + "responseBody": "HTML 响应体", + "bytes": "字节", + "securityRules": { + "path_traversal": { + "label": "路径穿越防护", + "tip": "检测 Path / Query / Body 中的 ../ 与编码变种" + }, + "file_inclusion": { + "label": "文件包含(LFI/RFI)", + "tip": "检测 Path / Query / Body 中的 php://、file://、/etc/passwd 等" + }, + "sql_injection": { + "label": "SQL 注入", + "tip": "检测 Query / Body / Header / Cookie 中的 SQL 注入特征" + }, + "command_injection": { + "label": "命令注入", + "tip": "检测 Query / Body / Header 中的 OS 命令注入特征" + }, + "xss": { + "label": "XSS", + "tip": "检测 Query / Body / Header 中的反射型 XSS 特征" + }, + "ssrf": { + "label": "SSRF", + "tip": "检测 Query / Body 中的内网地址与危险协议" + }, + "malicious_upload": { + "label": "恶意文件上传", + "tip": "检测 Multipart 文件名与危险扩展名" + }, + "xxe": { + "label": "XXE", + "tip": "检测 XML Body 中的外部实体特征" + }, + "crlf_injection": { + "label": "CRLF 注入", + "tip": "检测 Header / Query / Body 中的换行注入" + } + } + } + }, + "ipGroups": { + "title": "IP 组", + "create": "新建 IP 组", + "listTitle": "IP 组列表", + "listDesc": "维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。", + "refresh": "刷新", + "loading": "加载 IP 组中...", + "empty": "暂无 IP 组,请先创建一个。", + "updated": "IP 组已更新", + "created": "IP 组已创建", + "deleted": "IP 组已删除", + "synced": "同步完成", + "ipRemoved": "IP 已移除", + "testHit": "规则测试完成,命中 {count} 个 IP", + "testMiss": "规则测试完成,当前未命中任何 IP", + "deleteTitle": "确认删除 IP 组", + "deleteDesc": "确认删除 IP 组「{name}」吗?删除后无法恢复。", + "deleting": "删除中...", + "confirmDelete": "确认删除", + "operationFailed": "操作失败", + "types": { + "manual": "手动", + "automatic": "自动", + "subscription": "订阅" + }, + "enabled": "启用", + "disabled": "停用", + "noSyncRecord": "尚无同步记录", + "columns": { + "name": "名称", + "type": "类型", + "status": "状态", + "ipCount": "IP 数", + "refCount": "引用次数", + "syncStatus": "同步状态", + "updatedAt": "更新时间", + "actions": "操作" + }, + "actions": { + "view": "查看", + "edit": "编辑", + "test": "测试规则", + "runNow": "立即执行", + "syncNow": "立即同步", + "delete": "删除" + }, + "ban": { + "permanent": "永久", + "expired": "已过期", + "minutesLater": "{count} 分钟后", + "hoursLater": "{count} 小时后" + }, + "presets": { + "scan404": "单 IP 404 高频扫描", + "directAccess": "单 IP 直连访问异常" + }, + "dialog": { + "editTitle": "编辑 {name}", + "createTitle": "新建 IP 组", + "description": "维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。", + "name": "IP 组名称", + "nameRequired": "请输入 IP 组名称", + "type": "类型", + "enable": "启用 IP 组", + "enableDesc": "关闭后保留配置,但发布时不会展开到 WAF 运行时名单。", + "subscriptionUrl": "订阅 URL", + "subscriptionUrlRequired": "订阅类型需要填写订阅 URL", + "subscriptionFormat": "订阅格式", + "formatText": "文本列表", + "syncInterval": "同步间隔(分钟)", + "syncIntervalDesc": "最小 1 分钟,默认 1440 分钟。", + "mappingRule": "JSON 映射规则", + "mappingPlaceholder": "留空表示根数组", + "autoIntervalDesc": "定时从请求日志挖掘恶意 IP 的周期。最小 1 分钟,默认 1440 分钟。", + "presetRules": "预设规则", + "autoConfigJson": "自动配置 JSON", + "ipList": "IP / IP 段", + "ipListSubscriptionHint": "订阅同步会覆盖此列表;也可以先手动保存当前内容。", + "ipListManualHint": "支持单个 IP 或 CIDR,每行一个。", + "saving": "保存中...", + "save": "保存 IP 组" + }, + "testDialog": { + "title": "自动规则测试结果", + "description": "基于当前自动配置 JSON 对请求日志进行回看测试。", + "testing": "测试中...", + "summary": "回看 {lookback} · 规则 {rules} 条 · 命中 {matched} 个 IP", + "testedAt": "测试时间:{time}", + "noMatch": "当前没有匹配到任何 IP。", + "empty": "暂无测试结果。", + "close": "关闭" + }, + "viewDialog": { + "titleNamed": "查看 {name}", + "title": "查看 IP 组", + "summary": "{type} · 共 {count} 条 IP", + "fallbackDesc": "查看当前 IP 组中的 IP 列表,并可移除不需要的条目。", + "loading": "加载 IP 列表...", + "noGroup": "未选择 IP 组。", + "emptyAuto": "该自动规则暂未抓取任何 IP,可点击「立即执行」手动触发抓取。", + "empty": "当前 IP 组暂无 IP 条目。", + "subscriptionHint": "订阅类型在下次同步时可能重新拉取已删除的 IP。", + "ipAddress": "IP 地址", + "capturedAt": "抓取时间", + "banRemaining": "封禁剩余", + "actions": "操作", + "deleteIp": "删除 {ip}", + "close": "关闭", + "deleteTitle": "确认删除 IP", + "deleteDesc": "确认从 IP 组「{name}」中移除 {ip} 吗?", + "deleting": "删除中...", + "confirmDelete": "确认删除" + }, + "autoConfigMustBeObject": "自动配置必须是 JSON 对象。" + }, + "rateLimits": { + "title": "限流", + "subtitle": "查看边缘请求压力,并配置站点默认并发与带宽限流。", + "loadingAuth": "加载权限信息...", + "forbiddenTitle": "权限不足", + "forbiddenDesc": "只有管理员可以访问限流设置。", + "tabAnalysis": "分析", + "tabConfig": "配置", + "loadingPage": "加载限流页面...", + "loadingPressure": "加载请求压力...", + "loadFailed": "加载失败", + "emptyTitle": "暂无数据", + "emptyDesc": "当前时间范围内没有可用的访问日志。", + "topHost": "平均 RPS 最高域名", + "topIp": "平均 RPS 最高 IP", + "avgRate": "{range}平均请求速率", + "range": { + "h24": "24 小时", + "h72": "3 天" + }, + "chart": { + "title": "请求压力", + "empty": "暂无请求压力数据", + "requestRate": "请求速率", + "uniqueVisitors": "独立访客", + "visitorsPerBucket": "访客 / 桶" + }, + "config": { + "saved": "限流参数已保存", + "saveFailed": "保存失败", + "loading": "加载限流参数...", + "preview": "查看配置预览", + "title": "全局默认限流", + "description": "0 或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。", + "save": "保存", + "connPerServer": "默认并发限制(每站点)", + "connPerServerHint": "站点最大并发连接数默认值", + "connPerIp": "默认单 IP 并发", + "connPerIpHint": "单个 IP 最大并发连接数默认值", + "limitRate": "默认限速", + "limitRateHint": "单请求带宽默认值,例如 512k 或 1m", + "reqPerIp": "默认单 IP 请求频率限制", + "reqPerIpHint": "单个 IP 请求频率默认值,例如 10r/s 或 100r/m,留空关闭", + "invalidConn": "并发限制请输入非负整数,或留空表示关闭", + "invalidRate": "限速格式不合法,请使用 512k、1m、纯数字,或留空关闭", + "invalidReq": "请求频率限制格式不合法,请使用类似 10r/s、100r/m,或留空关闭" + } + }, + "pages": { + "title": "Pages", + "subtitle": "边缘静态站点托管,上传静态资源部署包并在代理规则中选择 Pages 上游。", + "refresh": "刷新", + "create": "新建项目", + "loadFailed": "加载失败", + "emptyTitle": "还没有 Pages 项目", + "emptyDesc": "先创建一个项目,再上传静态资源部署包。", + "enabled": "已启用", + "disabled": "已停用", + "spaFallback": "SPA fallback", + "strict404": "严格 404", + "currentActive": "当前激活", + "none": "暂无", + "activatedAt": "激活时间:", + "notActivated": "未激活", + "viewDetail": "查看详情", + "saveFailed": "保存失败", + "updated": "项目已更新", + "created": "项目已创建", + "deleted": "项目已删除", + "deleteFailed": "删除失败", + "editTitle": "编辑 Pages 项目", + "createTitle": "新建 Pages 项目", + "editorDesc": "配置静态站点托管参数,上传部署包后在代理规则中选择 Pages 上游。", + "settingsDesc": "配置静态站点托管参数,保存后会同步到项目详情与代理引用。", + "reset": "重置", + "saving": "保存中...", + "saveChanges": "保存修改", + "createProject": "创建项目", + "form": { + "name": "项目名称", + "nameRequired": "请输入项目名称", + "slug": "项目标识", + "slugPlaceholder": "留空自动生成", + "entryFile": "入口文件", + "entryRequired": "请输入入口文件", + "rootDir": "根目录", + "optional": "可选", + "spaFallback": "SPA fallback", + "spaFallbackDesc": "未命中静态文件时回退到指定路径", + "fallbackPath": "回退路径", + "fallbackPathPrefix": "回退路径必须以 / 开头", + "apiProxy": "API 反向代理", + "apiProxyDesc": "为静态站点附加 API 反代规则", + "matchPath": "匹配路径", + "matchPathPrefix": "匹配路径必须以 / 开头", + "backend": "后端地址", + "backendPrefix": "后端地址必须以 http:// 或 https:// 开头", + "rewrite": "重写规则" + }, + "detail": { + "invalidId": "缺少有效的 Pages 项目 ID。", + "loading": "加载项目详情...", + "loadFailed": "项目详情加载失败", + "back": "返回列表", + "notFound": "Pages 项目不存在或已被删除。", + "deploymentCount": "{count} 个部署", + "tabDeployments": "部署", + "tabSettings": "设置" + }, + "danger": { + "title": "危险区域", + "deleteProject": "删除项目", + "deleteHint": "将永久删除 {name}({slug})及其全部部署。", + "confirmTitle": "删除 Pages 项目", + "confirmDesc": "确认删除项目 {name} 吗?此操作不可恢复。", + "confirm": "确认删除" + }, + "upload": { + "selectFile": "请选择部署包", + "success": "部署包上传成功", + "failed": "上传失败", + "unsupported": "仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式的部署包", + "title": "上传部署包", + "description": "上传不可变的静态资源压缩包,完成后可在部署历史中激活。", + "localPackage": "本地部署包", + "dropHint": "拖拽部署包到此处,或点击选择文件", + "selected": "已选择 {name}({size})", + "chooseArchive": "请选择一个受支持的压缩包。", + "entry": "部署入口", + "entryHint": "入口来自项目设置;部署包上传不会覆盖该配置。", + "processing": "服务端处理中", + "progress": "上传进度", + "pleaseWait": "请稍候", + "uploading": "上传中...", + "submit": "上传并创建部署" + }, + "files": { + "loadFailed": "文件清单加载失败", + "empty": "暂无文件记录", + "path": "路径", + "size": "大小" + }, + "history": { + "source": { + "manual_upload": "本地上传", + "manual_url": "URL 导入" + }, + "trigger": { + "manual_upload": "手动上传", + "manual_url": "手动导入", + "manual_sync": "手动同步", + "scheduled_auto_update": "定时更新" + }, + "fileMeta": "{checksum} · {count} 个文件 · ", + "createdAt": "创建于 {time}", + "activatedAt": " · 激活于 {time}", + "collapseFiles": "收起文件清单", + "expandFiles": "展开文件清单", + "deploymentNumber": "部署 #{number}", + "activate": "激活", + "delete": "删除", + "activated": "历史部署已激活;自动更新(如已开启)已关闭", + "activateFailed": "激活失败", + "deleted": "部署已删除", + "deleteFailed": "删除失败", + "activateTitle": "激活历史部署", + "deleteTitle": "删除部署", + "activateDesc": "激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。", + "deleteDesc": "确认删除部署 #{number} 吗?此操作不可恢复。", + "confirm": "确认", + "loading": "加载部署历史...", + "loadFailed": "部署历史加载失败", + "productionHint": "当前对外生效的生产部署;切换激活状态后会立即生效。", + "manualUpload": "手动上传", + "emptyProductionTitle": "暂无 Production 部署", + "emptyProductionDesc": "上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。", + "historyHint": "部署记录不可变,来源信息是创建部署时的安全快照。可从历史部署激活或删除。", + "emptyTitle": "暂无部署", + "emptyDesc": "上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。" + }, + "source": { + "status": { + "idle": "空闲", + "checking": "检查中", + "update_available": "有可用更新", + "syncing": "同步中", + "failed": "最近同步失败", + "attention": "需要确认" + }, + "check": "检查", + "syncPublish": "同步并发布", + "missingExecutionId": "缺少任务执行 ID", + "actionDone": "部署源{action}完成", + "actionFailed": "部署源{action}失败", + "checkQueued": "检查任务已提交", + "checkQueueFailed": "检查任务提交失败", + "syncQueued": "同步任务已提交", + "syncQueueFailed": "同步任务提交失败", + "title": "部署源", + "loadingDesc": "加载来源配置...", + "independentDesc": "来源配置与部署历史相互独立。", + "loadFailed": "部署源加载失败", + "configDesc": "配置远端来源并同步发布;发布结果见部署历史。", + "manualBadge": "手动部署", + "localPackage": "本地部署包", + "noRemote": "当前没有持久化远端来源。上传部署包后,再从部署历史显式激活。", + "submitFailed": "来源任务提交失败", + "statusFailed": "任务状态读取失败", + "waitStopped": "自动等待已停止,任务可能仍在后台运行。", + "refreshStatus": "刷新任务状态", + "configure": "配置", + "checkUpdate": "检查更新", + "refresh": "刷新", + "confirmTitle": "确认发布当前 GitHub revision", + "confirmLead": "这将发布卡片当前显示的精确 revision:", + "revisionUnavailable": "当前 revision 已不可用,请刷新后重试", + "confirmPublish": "确认并发布", + "updated": "部署源已更新", + "updateFailed": "部署源更新失败", + "switchedManual": "已切换回手动部署", + "switchFailed": "切换失败", + "urlRequired": "请输入 Remote URL", + "urlInvalid": "请输入有效的 HTTP(S) URL", + "githubUrlInvalid": "请输入 https://github.com/'{owner}'/'{repo}' 格式的公开仓库地址", + "tagInvalid": "Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)", + "assetInvalid": "Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符", + "intervalInvalid": "检查间隔须为 5–1440 分钟的整数", + "useManual": "使用手动部署", + "saveRemote": "保存 Remote 来源", + "saveGithub": "保存 GitHub 来源", + "settingsTitle": "部署源设置", + "settingsDesc": "选择部署来源。", + "mode": "来源类型", + "manual": "手动部署", + "manualHint": "保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。", + "urlHint": "填写可直接下载的部署包 HTTP(S) 地址。", + "allowInsecure": "允许不安全的连接", + "allowInsecureDesc": "默认允许公网与内网地址;开启后跳过 TLS 证书校验,适用于自签名或私有 CA。", + "switchManualTitle": "切换回手动部署", + "switchManualDesc": "当前来源配置将被删除,但已有部署与当前生产版本会保留。", + "githubUrl": "GitHub 仓库 URL", + "githubUrlHint": "仅支持公开 github.com 仓库。", + "releaseSelect": "Release 选择", + "latestRelease": "最新 Release", + "pinnedTag": "固定 Tag", + "pinnedTagHint": "精确检查并同步指定 tag。", + "autoUpdate": "自动更新", + "autoUpdateHint": "检查到新的 Release 后自动同步并发布。", + "checkInterval": "检查间隔(分钟)", + "checkIntervalHint": "可设置为 5–1440 分钟。", + "assetName": "Release Asset 文件名", + "noRecord": "尚无记录", + "address": "地址", + "verifyCert": "校验证书", + "lastSync": "最近同步", + "neverSynced": "尚未同步", + "applied": "已应用", + "latestReleaseLabel": "最新 Release", + "pinnedTagLabel": "固定 Tag · {tag}", + "tagMissing": "未提供", + "assetChangedTitle": "Release Asset 发生变化,需要显式确认", + "assetChangedDesc": "当前远端 revision 与已发布内容不一致。请核对版本和资源后,再确认发布这一精确 revision。", + "autoUpdateOn": "已开启", + "autoUpdateOff": "已关闭", + "intervalMinutes": "{count} 分钟", + "nextCheck": "下次检查", + "waitingSchedule": "等待调度", + "lastCheck": "最近检查", + "neverChecked": "尚未检查", + "remote": "远端" + } + }, + "accessLogs": { + "title": "访问日志", + "subtitle": "查看访问概览、IP 明细与请求日志。", + "refresh": "刷新", + "cleanup": "清理日志", + "tabOverview": "概览", + "tabIps": "IP 明细", + "tabList": "日志明细", + "cleaned": "已清理 {count} 条日志", + "cleanupFailed": "清理失败", + "loadingTitle": "加载访问日志", + "loadingDesc": "正在准备页面...", + "loadFailed": "加载失败", + "range": { + "h24": "24 小时", + "h168": "7 天", + "h360": "15 天", + "h720": "30 天" + }, + "rangeHint": { + "hours24": "近 24 小时", + "days": "近 {count} 天", + "hours": "近 {count} 小时" + }, + "sort": { + "logged_at:desc": "时间从新到旧", + "logged_at:asc": "时间从旧到新", + "status_code:desc": "状态码从高到低", + "status_code:asc": "状态码从低到高", + "remote_addr:asc": "IP 正序", + "remote_addr:desc": "IP 倒序", + "total_requests:desc": "请求数从高到低", + "total_requests:asc": "请求数从低到高", + "request_length:desc": "入站从高到低", + "request_length:asc": "入站从低到高", + "bytes_sent:desc": "出站从高到低", + "bytes_sent:asc": "出站从低到高", + "success_ratio:desc": "2xx 比例从高到低", + "success_ratio:asc": "2xx 比例从低到高", + "last_seen_at:desc": "最后访问从新到旧", + "last_seen_at:asc": "最后访问从旧到新" + }, + "cache": { + "hit": "命中", + "origin": "回源", + "uncached": "未缓存" + }, + "filters": { + "pickTime": "选择时间", + "remoteAddr": "来源 IP", + "remoteAddrPlaceholder": "按 IP 搜索", + "host": "访问域名", + "hostPlaceholder": "按域名搜索", + "statusCode": "状态码", + "statusCodePlaceholder": "输入状态码,如 404", + "more": "更多筛选", + "nodeId": "节点 ID", + "nodeIdPlaceholder": "按 node_id 搜索", + "path": "请求路径", + "pathPlaceholder": "按路径搜索", + "timeRange": "时间范围", + "pageSize": "每页条数", + "pageSizeItem": "{count} 条", + "apply": "筛选", + "clear": "清空" + }, + "cleanupDialog": { + "invalidDays": "保留天数必须大于 0", + "title": "清理访问日志", + "description": "删除早于指定保留天数的访问日志记录,操作不可恢复。", + "policy": "保留策略", + "keepDays": "保留最近 {days} 天", + "custom": "自定义天数", + "customDays": "自定义保留天数", + "cleaning": "清理中...", + "confirm": "确认清理" + }, + "detail": { + "title": "访问日志详情", + "description": "本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。", + "logId": "日志 ID", + "requestTime": "请求时间", + "storedAt": "入库时间", + "node": "节点", + "nodeId": "节点 ID", + "clientIp": "客户端 IP", + "region": "地区", + "host": "域名", + "status": "状态码", + "cache": "缓存", + "bytesSent": "出站流量", + "requestLength": "入站流量", + "duration": "请求耗时", + "path": "路径", + "page": "当前第 {page} 页", + "prev": "上一页", + "next": "下一页", + "listTitle": "日志明细", + "empty": "暂无访问日志", + "time": "时间", + "view": "查看详情" + }, + "ip": { + "needRange": "请填写开始与结束时间", + "invalidTime": "时间格式无效", + "endAfterStart": "结束时间必须晚于开始时间", + "maxRange": "时间范围不能超过 30 天", + "page": "当前第 {page} 页", + "total": " · 共 {count} 个 IP", + "prev": "上一页", + "next": "下一页", + "customRange": "自定义区间", + "customPending": "自定义(未应用)", + "timeRange": "时间范围", + "current": "当前:{range}", + "custom": "自定义", + "start": "开始", + "end": "结束", + "apply": "应用", + "applyHint": "修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。", + "sort": "排序", + "pageSize": "每页", + "title": "IP 明细", + "empty": "暂无 IP 数据", + "region": "地区", + "requests": "请求数", + "successRatio": "2xx 比例", + "inbound": "入站", + "outbound": "出站", + "lastSeen": "最后访问", + "view": "查看 IP 详情", + "dialogTitle": "IP 详情", + "dialogDesc": "。查看该 IP 的访问趋势、分布与 WAF IP 组操作。" + }, + "analysis": { + "emptySeries": "暂无 {title} 数据", + "selectGroup": "请选择要加入的 IP 组", + "added": "已将 {ip} 加入 IP 组「{name}」", + "addFailed": "加入 IP 组失败", + "removed": "已从 IP 组「{name}」移除 {ip}", + "removeFailed": "移除失败", + "joinTitle": "将 IP 加入 IP 组", + "targetIp": "目标 IP:", + "loadingGroups": "加载 IP 组", + "loadGroupsFailed": "加载 IP 组失败", + "alreadyIn": "该 IP 已存在于以下 IP 组", + "alreadyInHint": "可选择删除,或继续添加到其他 IP 组。", + "entryCount": "{type} · {count} 条", + "delete": "删除", + "cannotDelete": "不可手动删除", + "notInAny": "该 IP 尚未加入任何 IP 组。", + "addToOther": "添加到其他 IP 组", + "noWritable": "没有可写入的手动 IP 组(或已全部包含该 IP)。", + "selectManual": "选择手动 IP 组", + "close": "关闭", + "processing": "处理中...", + "joinSelected": "加入所选 IP 组", + "recentHours": "近 {hours} 小时", + "invalidIp": "没有有效 IP。", + "addToGroup": "将 IP 加入到 IP 组", + "loading": "加载 IP 分析", + "loadFailed": "加载 IP 分析失败", + "totalRequests": "总请求", + "errors": "错误数", + "bandwidth": "已提供带宽", + "received": "接收数据", + "uniqueHosts": "独立域名", + "uniquePaths": "独立路径", + "trend": "IP 请求趋势", + "trendMeta": "{ip} · {range} · {minutes} 分钟桶", + "refresh": "刷新", + "trendFailed": "加载趋势失败", + "trendEmpty": "该 IP 在{range}内没有访问记录。" + }, + "overview": { + "loadingTitle": "加载访问概览", + "loadingDesc": "正在聚合请求量、访问量与带宽趋势...", + "loadFailed": "加载访问概览失败", + "emptyTitle": "暂无概览数据", + "emptyDesc": "当前时间范围内没有可展示的访问统计。", + "uniqueVisitors": "{hint} · 独立访客", + "served": "{hint} · 已提供数据", + "requestsHint": "观察请求量是否出现异常抬升或回落。", + "requests": "请求量", + "deviceDesc": "按设备类型统计请求占比。", + "deviceEmpty": "暂无设备类型数据", + "statusDesc": "按 HTTP 状态码统计请求占比。", + "statusEmpty": "暂无状态码分布数据", + "topPathsDesc": "访问量最高的请求路径。", + "topHostsDesc": "流量集中的访问域名。", + "topIpsDesc": "请求次数最多的来源 IP。", + "browsersDesc": "按浏览器聚合的请求排行。", + "osDesc": "按操作系统聚合的请求排行。", + "uaDesc": "原始 User-Agent 请求排行。" + }, + "toolbar": { + "hostsFiltered": "已筛选 {count} 个域名", + "filterByHost": "按域名筛选", + "filterHosts": "筛选域名", + "clear": "清除", + "searchPlaceholder": "搜索 Zone 或域名", + "loadingHosts": "加载域名…", + "loadHostsFailed": "加载域名失败", + "retry": "重试", + "noRegistered": "暂无已登记域名", + "noMatch": "没有匹配的域名", + "selectZone": "选择 Zone {name}", + "selectHost": "选择域名 {name}", + "selectedCount": "已选 {count} 个域名" + } + }, + "cloudflare": { + "title": "Cloudflare", + "connectionSettings": "连接设置", + "addGroup": "新增分组", + "loadingOverview": "加载 Cloudflare 总览中...", + "notReadyTitle": "Cloudflare 连接尚未就绪", + "notReadyDesc": "请先导入现有 Cloudflare DNS 账号,或录入独立 API Token 并完成连接测试。", + "configure": "配置连接", + "groupsTitle": "指向分组", + "groupsDesc": "管理 Cloudflare A 记录对应的节点与域名成员。", + "loadingGroups": "加载指向分组中...", + "emptyTitle": "暂无指向分组", + "emptyDesc": "创建分组后,可将域名成员同步到指定的边缘节点。", + "enabled": "已启用", + "disabled": "已停用", + "memberSummary": "成员 {count} 个 · 新成员默认{proxy}橙云", + "proxyOn": "开启", + "proxyOff": "关闭", + "manage": "管理", + "sync": "同步", + "delete": "删除", + "created": "指向分组已创建", + "syncQueued": "分组同步任务已入队", + "deleted": "指向分组及远端记录已删除", + "deleteTitle": "删除指向分组", + "deleteDesc": "将删除 {name} 的全部成员及本模块管理的远端 A 记录。此操作不可撤销。", + "confirmDelete": "确认删除", + "loadingDetail": "加载分组详情中...", + "groupUpdated": "分组配置已更新", + "memberAddedOne": "域名已加入并排队同步", + "memberAddedMany": "已添加 {count} 个域名并排队同步", + "memberAddFailed": "添加失败:{count} 个域名未能加入", + "memberPartial": "部分成功:{succeeded} 个已加入,{failed} 个失败", + "proxyUpdated": "橙云设置已更新并排队同步", + "memberSyncQueued": "成员同步任务已入队", + "memberDeleted": "成员及远端 A 记录已删除", + "back": "返回列表", + "refresh": "刷新", + "edit": "编辑", + "addDomain": "添加域名", + "currentPoint": "当前指向", + "activeNode": "生效节点 {name} · {ip}", + "syncEnabled": "同步已启用", + "syncDisabled": "同步已停用", + "primaryNode": "主节点 {name}", + "backupNode": "备用节点 {name}", + "backupUnset": "未设置", + "members": "域名成员", + "membersDesc": "成员级橙云是同步时的唯一依据。", + "noMembers": "暂无域名成员。", + "columns": { + "domain": "域名", + "desiredIp": "期望 IP", + "status": "状态", + "proxied": "橙云", + "actions": "操作" + }, + "pendingSync": "待同步", + "remove": "移出", + "groupDialog": { + "editTitle": "编辑指向分组", + "createTitle": "新增指向分组", + "description": "一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。", + "name": "分组名称", + "primary": "主节点", + "primaryPlaceholder": "选择带 IPv4 的边缘节点", + "noIp": "未配置 IP", + "backup": "备用节点", + "none": "不设置", + "defaultProxied": "新成员默认开启橙云", + "enableSync": "启用同步", + "save": "保存" + }, + "memberDialog": { + "title": "添加域名成员", + "description": "支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名 A 会拒绝同步。", + "zoneDomain": "Zone 域名", + "searchPlaceholder": "搜索域名或顶级域…", + "selected": "已选 {count}", + "visible": "可见 {count}", + "unselectAll": "取消全选", + "selectVisible": "全选可见", + "clear": "清空", + "noAvailable": "暂无可用域名(均已加入分组,或尚未在网站管理中添加)。", + "noMatch": "没有匹配的域名", + "groupByZone": "按顶级域分组", + "selectZone": "选择顶级域 {name}", + "zone": "顶级域", + "proxied": "开启橙云代理", + "submitCount": "添加并同步({count})", + "submit": "添加并同步" + }, + "tasks": { + "pending": "等待中", + "running": "执行中", + "succeeded": "成功", + "failed": "失败", + "system": "系统", + "manual": "手动", + "retry": "重试", + "schedule": "定时", + "retried": "同步任务已重新下发", + "newTaskId": "新任务 ID:{id}", + "retryFailed": "任务重试失败", + "unknownError": "未知错误", + "title": "同步任务", + "description": "仅展示 Cloudflare 域名同步(sync_member)与分组同步(sync_group)的执行记录。", + "statusPlaceholder": "状态", + "allStatus": "全部状态", + "refresh": "刷新", + "loadFailed": "加载同步任务失败", + "loading": "加载同步任务中...", + "emptyTitle": "暂无同步任务", + "emptyDesc": "添加域名或同步分组后,这里会显示 Cloudflare 域名 / 分组同步执行记录。", + "columns": { + "task": "任务", + "status": "状态", + "trigger": "触发", + "duration": "耗时", + "result": "结果/错误", + "createdAt": "创建时间" + }, + "pageSummary": "共 {total} 条,第 {page}/{pages} 页", + "prev": "上一页", + "next": "下一页", + "detailTitle": "同步任务详情", + "defaultName": "Cloudflare 同步", + "loadingDetail": "加载任务详情中...", + "status": "状态", + "trigger": "触发", + "retries": "重试", + "duration": "耗时", + "taskId": "任务 ID", + "createdAt": "创建时间", + "finishedAt": "结束时间", + "result": "执行结果", + "error": "错误信息", + "logs": "执行日志", + "close": "关闭" + }, + "settings": { + "saved": "Cloudflare 连接配置已保存", + "verified": "Cloudflare 连接验证成功", + "cleared": "Cloudflare 连接已清除", + "back": "返回列表", + "title": "Cloudflare 连接设置", + "sourceTitle": "凭据来源", + "sourceDesc": "Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API 或页面中回显。", + "source": "连接来源", + "importDns": "导入现有 DNS 账号", + "standalone": "独立 API Token", + "dnsAccount": "Cloudflare DNS 账号", + "selectAccount": "选择 DNS 账号", + "dnsHint": "仅显示类型为 cloudflare 的 DNS 账号。可前往", + "dnsLink": "DNS 账号", + "dnsHintAfter": "新增。", + "tokenKeep": "留空不会回显现有 Token;保存将替换", + "tokenEnter": "输入 Cloudflare API Token", + "save": "保存配置", + "test": "测试连接", + "clear": "清除连接", + "statusTitle": "当前状态", + "ready": "Token 已验证,Cloudflare 指向同步可以执行。", + "notReady": "保存配置后仍需测试连接;未验证状态下同步任务会被拒绝。" + } + }, + "openflareOps": { + "saved": "OpenFlare 运维设置已保存", + "saveFailed": "保存失败", + "tokenRotated": "Discovery Token 已重新生成", + "tokenRotateFailed": "Token 轮换失败", + "kumaSynced": "Uptime Kuma 同步任务已执行", + "syncFailed": "同步失败", + "invalidParams": "参数校验失败", + "loading": "加载 OpenFlare 运维设置...", + "loadFailed": "加载失败", + "save": "保存", + "agent": { + "title": "Agent 运行参数", + "description": "心跳间隔与离线阈值会在下个心跳周期同步到节点。", + "heartbeat": "心跳间隔 ({duration})", + "offline": "离线阈值 ({duration})", + "wsUpgrade": "开启 WS 连接升级", + "wsUpgradeDesc": "HTTP 心跳成功后尝试升级为 WebSocket,配置发布可即时通知。", + "updateRepo": "Agent 更新仓库" + }, + "geo": { + "title": "IP 归属解析", + "description": "控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用 MaxMind mmdb。", + "mode": "归属方式", + "disabled": "关闭", + "testIp": "测试 IP", + "querying": "查询中...", + "query": "查询归属", + "country": "国家/地区" + }, + "pages": { + "title": "Pages 静态托管", + "description": "配置部署包上传体积上限与每个项目的历史部署保留数量。", + "maxSize": "部署包大小上限 (MiB)", + "history": "历史部署保留数(0 不限制)", + "historyHint": "每个项目最多保留 N 条部署:激活部署始终保留,其余按从新到旧填充;超出的非激活部署会在上传成功后自动清理。支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式。" + }, + "discovery": { + "title": "Discovery Token 与部署", + "description": "新节点首次接入使用 Discovery Token;轮换请前往节点管理或使用下方操作。", + "manageNodes": "节点管理", + "rotate": "轮换 Token", + "tokenReadonly": "Discovery Token(只读)", + "loading": "加载中...", + "notGenerated": "未生成", + "command": "一键部署命令", + "commandHint": "请先填写可访问的 Server URL 并获取 Token。", + "copied": "命令已复制", + "copy": "复制命令" + }, + "kuma": { + "title": "Uptime Kuma 集成", + "description": "将反代站点差分同步至 Uptime Kuma 监控实例。", + "syncNow": "立即同步", + "enable": "开启 Uptime Kuma", + "url": "Uptime Kuma 地址", + "username": "用户名", + "password": "密码", + "passwordKeep": "留空表示不更新", + "syncInterval": "同步间隔 (分钟)", + "scope": "监控范围", + "allSites": "全部站点", + "selectedSites": "选择站点", + "selectedLabel": "已选站点", + "pickSites": "选择监控站点", + "noneSelected": "未选择任何站点", + "interval": "检测频率 (秒)", + "retry": "重试次数", + "retryInterval": "重试间隔 (秒)", + "timeout": "请求超时 (秒)" + }, + "duration": { + "minutes": "{count} 分钟", + "seconds": "{count} 秒" + }, + "errors": { + "heartbeatMin": "心跳间隔不能小于 1000 毫秒。", + "offlineMin": "离线阈值不能小于 10000 毫秒。", + "offlineRatio": "离线阈值建议至少为心跳间隔的 3 倍。", + "kumaUrl": "请输入 Uptime Kuma 地址。", + "kumaUser": "请输入 Uptime Kuma 用户名。", + "syncInterval": "同步间隔必须为正整数。", + "interval": "心跳间隔必须为正整数。", + "retry": "重试次数必须为非负整数。", + "retryInterval": "心跳重试间隔必须为正整数。", + "timeout": "请求超时必须为正整数。", + "pagesSize": "Pages 部署包大小上限必须为 1~2048 MiB。", + "pagesHistory": "Pages 历史保留数必须为大于等于 0 的整数(0 表示不限制)。" + }, + "status": { + "title": "系统状态", + "loadFailed": "获取系统状态失败", + "unknownError": "请求时发生未知错误", + "autoRefresh": "自动刷新", + "refresh": "刷新", + "overview": "服务概览", + "overviewDesc": "服务的基础生命指标", + "uptime": "服务运行时间", + "goroutines": "当前 Goroutines 数量", + "heapObjects": "Heap 对象数量", + "memory": "内存统计", + "memoryDesc": "主内存消耗概览", + "alloc": "当前内存使用量", + "totalAlloc": "所有已分配的内存", + "sys": "内存占用量", + "nextGc": "下次 GC 内存回收量", + "usageRatio": "当前使用率 (Alloc / Sys)", + "heapStack": "堆/栈详情", + "heapStackDesc": "运行时堆栈内存空间细节", + "heapAlloc": "当前 Heap 内存使用量", + "heapSys": "Heap 内存占用量", + "heapIdle": "Heap 内存空闲量", + "heapInuse": "正在使用的 Heap 内存", + "heapReleased": "已释放的 Heap 内存", + "stackInuse": "启动 Stack 使用量", + "stackSys": "已分配的 Stack 内存", + "structs": "底层及结构体内存", + "structsDesc": "运行时底层管理结构体开销", + "mspanInuse": "MSpan 结构内存使用量", + "mspanSys": "已分配的 MSpan 结构内存", + "mcacheInuse": "MCache 结构内存使用量", + "mcacheSys": "已分配的 MCache 结构内存", + "buckHashSys": "已分配的剖析哈希表内存", + "gcSys": "已分配的 GC 元数据内存", + "otherSys": "其它已分配的系统内存", + "gc": "垃圾回收与分配计数", + "gcDesc": "GC 历史数据与分配频次", + "numGc": "GC 执行次数", + "lastGc": "距离上次 GC 时间", + "pauseTotal": "GC 暂停时间总量", + "lastPause": "上次 GC 暂停时间", + "mallocs": "内存分配次数", + "frees": "内存释放次数", + "lookups": "指针查找次数" + }, + "siteModal": { + "title": "选择监控站点", + "description": "选择要同步到 Uptime Kuma 的反代站点,支持按站点名称和域名搜索。", + "search": "搜索站点", + "searchPlaceholder": "按名称或域名搜索...", + "selectFiltered": "全选过滤项", + "clearFiltered": "清空过滤项", + "loading": "加载站点列表...", + "loadFailed": "加载站点失败", + "noMatch": "无匹配的站点", + "select": "选择", + "siteName": "站点名称", + "primaryDomain": "主域名", + "selectedCount": "已选择 {count} 个监控站点", + "save": "保存选择" + } + } +} diff --git a/frontend/messages/fragments/websites.en.json b/frontend/messages/fragments/websites.en.json new file mode 100644 index 00000000..16432c97 --- /dev/null +++ b/frontend/messages/fragments/websites.en.json @@ -0,0 +1,582 @@ +{ + "websites": { + "title": "Websites", + "createZone": "Add Zone", + "searchRoot": "Search Zone root domain", + "loadingList": "Loading Zone list...", + "requestFailed": "Request failed. Please try again later.", + "emptySearch": "No matching Zone found.", + "emptyList": "No Zones yet. Click “Add Zone” in the top right to get started.", + "rootDomain": "Root domain", + "domainCount": "Domains", + "createdAt": "Created", + "updatedAt": "Updated", + "actions": "Actions", + "manage": "Manage", + "manageZone": "Manage Zone", + "loadingDetail": "Loading Zone details...", + "invalidId": "Invalid website ID. Open the details page from the website list.", + "notFound": "Website not found. It may have been deleted or the ID is invalid.", + "back": "Back", + "detailSubtitle": "Zone website management", + "tabOverview": "Overview", + "tabDomains": "Domains ({count})", + "tabCertificates": "Certificates ({count})", + "basicInfo": "Basic information", + "basicInfoDesc": "Maintain the Zone’s registrable root domain. After changing the root, confirm that its domains still belong to it.", + "currentRoot": "Current root domain", + "editRoot": "Edit root domain", + "dangerZone": "Dangerous actions", + "dangerZoneDesc": "Clear all domains under the Zone before deleting it. Deletion cannot be undone.", + "deleteZone": "Delete Zone", + "deleteZoneTitle": "Delete Zone", + "deleteZoneDesc": "Delete {domain}? Domains under it must be unbound from routes first. This cannot be undone.", + "deleting": "Deleting…", + "confirmDelete": "Delete", + "zoneDeleted": "Zone deleted", + "editZone": "Edit Zone", + "editorDesc": "A Zone accepts only a registrable root domain, such as example.com.", + "rootRequired": "Enter a Zone root domain", + "rootInvalid": "Enter a root domain without a protocol or wildcard", + "zoneUpdated": "Zone updated", + "zoneCreated": "Zone created", + "saveFailed": "Save failed", + "saveChanges": "Save changes", + "range24h": "24 hours", + "range7d": "7 days", + "range30d": "30 days", + "chartUv": "Bucket UV", + "chartBytes": "Data served", + "statsFailed": "Failed to load traffic stats", + "analyticsUnavailable": "Analytics storage is unavailable, so charts may be empty. Confirm that ClickHouse is enabled.", + "uniqueVisitors": "Unique visitors in query window", + "uniqueVisitorsDesc": "The top total is de-duplicated across the whole window; the curve is UV per time bucket and buckets cannot be added together", + "totalRequests": "Total requests", + "totalBytes": "Total data served", + "zoneInfo": "Zone information", + "addDomain": "Add domain", + "emptyDomains": "No domains added yet", + "certificate": "Certificate", + "upstream": "Upstream", + "certNumber": "Certificate #{id}", + "unbound": "Unbound", + "unlinkedRoute": "No linked route", + "loadingEllipsis": "Loading…", + "upstreamUnset": "No upstream configured", + "viewRoute": "View route details", + "deleteDomain": "Delete domain", + "domainDeleted": "Domain deleted", + "deleteFailed": "Delete failed", + "deleteDomainTitle": "Delete domain", + "deleteDomainDesc": "Delete {domain}? This cannot be undone.", + "zoneCertsTitle": "Certificates used by this Zone", + "zoneCertsDesc": "Certificates are managed in the global certificate library. This list only shows certificates bound to domains in this Zone.", + "certLibrary": "Certificate library", + "unboundCertHint": "{count} domains still have no certificate. Choose one in the Domains tab.", + "emptyBoundCerts": "No bound certificates. Choose a certificate on a domain row, or import/apply one in the certificate library.", + "status": "Status", + "coverage": "Covered domains", + "expiresAt": "Expires", + "selectZone": "Select a Zone first", + "enterDomain": "Enter a domain", + "noWildcard": "Zone domains do not support wildcards", + "invalidFormat": "Invalid domain format", + "invalidSubdomain": "Invalid subdomain name format", + "mustBelongToZone": "The domain must belong to Zone {root}", + "domainAdded": "Domain added", + "addFailed": "Add failed", + "quickCreateTitle": "Quick add domain", + "quickCreateDesc": "After selecting a Zone, enter a subdomain name (such as api), @ (the root), or a full FQDN.", + "selectRegisteredRoot": "Select registered root domain", + "zoneLabel": "Zone:", + "domain": "Domain", + "domainPlaceholderWithZone": "api or @ or api.{domain}", + "domainPlaceholder": "api / @ / full domain", + "willCreate": "Will create:", + "domainExample": "Example: enter api → api.zone.com; @ → the root itself", + "certOptional": "Certificate (optional)", + "noCert": "No certificate" + }, + "certificates": { + "title": "Certificates", + "refresh": "Refresh certificates", + "importCert": "Import certificate", + "applyTitle": "Apply for certificate", + "listTitle": "Certificate list", + "listDesc": "Shows certificate validity, remarks, and status. You can view details, edit contents, or delete certificates.", + "loadingList": "Loading certificate list...", + "requestFailed": "Request failed. Please try again later.", + "emptyList": "No certificates yet. Click “Import certificate” or “Apply for certificate” in the top right to get started.", + "notBeforeLabel": "Valid from: {value}", + "notAfterLabel": "Expires: {value}", + "sourceLabel": "Source: {value}", + "sourceAcme": "ACME application", + "sourceUpload": "Manual upload", + "statusLabel": "Status: {value}", + "converting": "Conversion in progress...", + "applying": "Applying...", + "statusError": "Status: {value} ({message})", + "convertFailed": "Conversion failed", + "applyFailed": "Application failed", + "remarkLabel": "Remark: {value}", + "noRemark": "No remark", + "view": "View", + "edit": "Edit", + "renew": "Renew", + "deleted": "Certificate deleted", + "renewSubmitted": "Renewal task submitted for certificate {name}", + "imported": "Certificate {name} imported", + "applySubmitted": "Application task submitted for certificate {name}", + "convertSubmitted": "Conversion application submitted for certificate {name}", + "reapplySubmitted": "Certificate {name} updated and is being re-applied...", + "updated": "Certificate {name} updated", + "deleteTitle": "Delete certificate", + "deleteDesc": "Delete certificate {name}?", + "applyEditTitle": "Edit and re-apply certificate", + "applyConvertTitle": "Convert to applied certificate", + "applyEditDesc": "Change the ACME certificate settings. After saving, the certificate will be re-applied with the new settings.", + "applyConvertDesc": "Fill in the ACME application details. After a successful application, the current manual certificate is converted in place to an auto-renewable applied certificate.", + "applyDesc": "Use ACME (Let's Encrypt and others) to automatically apply for and renew certificates, including wildcard domains.", + "name": "Certificate name", + "namePlaceholder": "For example: main site certificate", + "primaryDomain": "Primary domain", + "primaryDomainPlaceholder": "example.com or *.example.com", + "otherDomains": "Other domains", + "otherDomainsHint": "One domain per line. For a wildcard certificate, also enter the corresponding root domain so it can be issued together.", + "dnsAccount": "DNS provider account", + "selectDnsAccount": "Select a DNS account", + "keyAlgorithm": "Key algorithm", + "remark": "Remark", + "remarkPlaceholder": "Optional. Record what this certificate is used for.", + "autoRenew": "Enable auto-renewal", + "autoRenewDesc": "When enabled, the certificate is renewed automatically 7 days before it expires.", + "advanced": "Advanced options", + "dnsServer1": "DNS validation server 1", + "dnsServer2": "DNS validation server 2", + "dnsServerPlaceholder": "Leave empty to use the default authoritative DNS", + "skipCname": "Skip CNAME check", + "skipCnameDesc": "Do not follow CNAME records during DNS-01 validation.", + "skipDns": "Skip DNS pre-check", + "skipDnsDesc": "Ask Let's Encrypt to validate directly without a local check.", + "submitting": "Submitting...", + "saveAndApply": "Save and apply", + "startConvert": "Start conversion", + "startApply": "Start application", + "editTitle": "Edit certificate", + "editDesc": "You can change the certificate name and remark, and re-upload the PEM certificate and private key.", + "loadingContent": "Loading certificate contents...", + "notFound": "Certificate not found. It may have been deleted.", + "certPem": "Certificate PEM", + "keyPem": "Private key PEM", + "convertSource": "Convert source", + "saving": "Saving...", + "saveCert": "Save certificate", + "importTitle": "Add certificate", + "importDesc": "Paste PEM manually or upload certificate files. After import, you can select it immediately in website forms.", + "manualImport": "Manual import", + "fileImport": "File import", + "remarkProdPlaceholder": "For example: production certificate for the main site", + "importing": "Importing...", + "remarkWildcardPlaceholder": "For example: production wildcard certificate", + "certFile": "Certificate file", + "keyFile": "Private key file", + "fileSelected": "Selected: {name}", + "selectCertFile": "Select a PEM/CRT file", + "selectKeyFile": "Select a KEY/PEM file", + "uploading": "Uploading...", + "uploadFiles": "Upload files", + "clearFiles": "Clear files", + "selectFiles": "Select a certificate file and a private key file.", + "detailTitle": "Certificate details", + "detailDesc": "View certificate metadata, remarks, and the currently saved PEM contents.", + "loadingDetail": "Loading certificate details...", + "status": "Status", + "notBefore": "Valid from", + "notAfter": "Expires", + "certPemCopied": "Certificate PEM copied", + "keyPemCopied": "Private key PEM copied", + "copy": "Copy", + "close": "Close", + "editCert": "Edit certificate", + "deleting": "Deleting...", + "deleteCert": "Delete certificate", + "certStatus": { + "unknownExpiry": "Expiry unknown", + "expired": "Expired", + "expiresInDays": "Expires within {days} days", + "valid": "Valid" + }, + "validation": { + "nameRequired": "Enter a certificate name", + "nameTooLong": "Certificate name cannot exceed 255 characters", + "certPemRequired": "Enter the certificate PEM", + "keyPemRequired": "Enter the private key PEM", + "remarkTooLong": "Remark cannot exceed 255 characters", + "primaryDomainRequired": "Enter a primary domain", + "dnsAccountRequired": "Select a DNS account" + } + }, + "dnsAccounts": { + "title": "DNS accounts", + "addAccount": "Add account", + "listTitle": "DNS account list", + "listDesc": "Centrally manage DNS provider accounts used for ACME certificate DNS validation.", + "loadingList": "Loading DNS accounts...", + "requestFailed": "Request failed. Please try again later.", + "emptyList": "No DNS accounts yet. Click “Add account” in the top right to get started.", + "createdAt": "Created: {date}", + "created": "DNS account added", + "deleted": "DNS account deleted", + "deleteTitle": "Delete DNS account", + "deleteDesc": "Delete DNS account {name}?", + "createTitle": "Add DNS account", + "createDesc": "Centrally manage DNS provider accounts used for ACME certificate DNS validation.", + "accountName": "Account name", + "accountNamePlaceholder": "Cloudflare email account", + "provider": "DNS provider", + "tokenPlaceholder": "Do not use a Global API Key", + "nameRequired": "Enter a name", + "tokenRequired": "Enter a token", + "submitting": "Submitting...", + "submit": "Submit" + }, + "origins": { + "title": "Origins", + "subtitle": "Centrally maintain origin addresses reused by routes, so bulk address changes need less repeated work.", + "refresh": "Refresh", + "create": "Add origin", + "loadFailed": "Load failed", + "emptyTitle": "No origins", + "emptyDesc": "Click Add origin in the top right. Later routes can reuse these addresses directly.", + "routeCount": "{count} routes", + "noRemark": "No remark", + "lastUpdated": "Last updated: {date}", + "detail": "Details", + "edit": "Edit", + "deleted": "Origin deleted", + "deleteFailed": "Delete failed", + "deleteTitle": "Delete origin", + "deleteDesc": "Delete origin {name}?", + "confirmDelete": "Delete", + "missingId": "A valid origin ID is missing.", + "loadingDetail": "Loading origin details...", + "backToList": "Back to list", + "notFound": "Origin not found or already deleted.", + "editOrigin": "Edit origin", + "deleteOrigin": "Delete origin", + "boundRoutes": "Bound routes", + "createdAt": "Created", + "updatedAt": "Updated", + "remark": "Remark", + "relatedRoutes": "Related routes", + "relatedRoutesDesc": "Shows routes that bind this origin as the primary origin.", + "noRelatedRoutes": "No related routes", + "noRelatedRoutesDesc": "This origin is not referenced by any route yet.", + "domain": "Domain", + "address": "Origin address", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "addressRequired": "Enter an origin address", + "addressInvalid": "Invalid origin address format", + "updated": "Origin updated", + "created": "Origin created", + "saveFailed": "Save failed", + "editorDesc": "Origins are a reusable address directory for routes. Protocol and port are still decided by the route.", + "name": "Origin name", + "namePlaceholder": "Main origin", + "saving": "Saving...", + "saveChanges": "Save changes" + }, + "proxyRoutes": { + "title": "Route configuration", + "refresh": "Refresh", + "createRule": "New route", + "listTitle": "Route list", + "searchPlaceholder": "Search sites, domains, or upstreams...", + "emptyTitle": "No websites", + "emptyDesc": "Create a website first, then open the configuration page to finish HTTPS, cache, and rate limits.", + "noMatches": "No matching results", + "noMatchesDesc": "Try a different search term, or clear the filter.", + "siteName": "Site name", + "domain": "Domain", + "status": "Status", + "upstream": "Upstream", + "actions": "Actions", + "enabled": "Enabled", + "disabled": "Disabled", + "configure": "Configure", + "siteCreated": "Website created", + "siteDeleted": "Website deleted", + "deleteFailed": "Delete failed", + "loadListFailed": "Failed to load route list", + "unknownError": "Unknown error", + "deleteSiteTitle": "Delete website", + "deleteSiteDesc": "Delete website {name}? This deletes all domains and configuration under the site.", + "deleting": "Deleting...", + "missingId": "A valid route ID is missing.", + "notFound": "Route not found", + "notFoundDesc": "Return to the list and try again.", + "saving": "Saving...", + "saveFailed": "Save failed", + "requestFailed": "Request failed. Please try again later.", + "back": "Back", + "publish": "Publish configuration", + "diffFailed": "Failed to load configuration diff", + "noPublishNeeded": "The current configuration matches the active version. Nothing to publish.", + "publishSuccess": "Published successfully", + "versionLabel": "Version {version}", + "publishFailed": "Publish failed", + "publishTitle": "Publish configuration", + "publishDesc": "This creates a new version from the pending configuration and sets it as active. Nodes will then pull the update.", + "confirmPublish": "Publish", + "createDesc": "Choose bindings from registered Zone domains. After creating the route, you can continue configuring cache, rate limits, and other advanced options.", + "siteId": "Site identifier", + "siteIdOptionalDesc": "Optional. If left empty, the first domain is used automatically.", + "siteIdStableDesc": "Use a stable, readable business identifier. It does not have to match the domain exactly.", + "bindDomains": "Bound domains", + "bindDomainsHint": "Select registered domains to bind. Use “Quick add domain” to create an FQDN under a Zone and select it automatically.", + "upstreamType": "Origin mode", + "upstreamDirect": "Direct upstream", + "upstreamTunnel": "Tunnel", + "upstreamPages": "Pages static site", + "upstreamAddresses": "Upstream addresses", + "upstreamUrlsHint": "One full URL per line. The first line is the primary origin. In multi-upstream mode, keep the same protocol and do not include a path or query.", + "selectTunnel": "Select a tunnel", + "pleaseSelect": "Please select...", + "online": "Online", + "offline": "Offline", + "tunnelForwardHint": "Forward requests to the client node connected by this tunnel.", + "tunnelProtocol": "Internal service protocol", + "tunnelAddress": "Internal service address", + "tunnelAddressHint": "For example: 127.0.0.1:8080 or 192.168.1.10:80", + "selectPagesProject": "Select a Pages project", + "pagesProjectHint": "Only enabled Pages projects with an active deployment are shown.", + "enableSite": "Enable site", + "enableSiteDesc": "When disabled, the configuration is kept but not included in publish.", + "createFailed": "Create failed. Please try again later.", + "creating": "Creating…", + "create": "Create", + "searchDomainOrZone": "Search domain or Zone…", + "quickAddDomain": "Quick add domain", + "noZoneDomains": "No Zone domains available. Add an FQDN in Website management first, or use “Quick add domain”.", + "noBindableDomains": "No bindable domains (the rest are already bound to other routes). Use “Quick add domain” to create one.", + "noMatchingDomains": "No matching domains", + "certNumber": "Certificate #{id}", + "noCert": "No certificate", + "zoneName": "Zone {name}", + "zoneId": "Zone #{id}", + "unboundDomain": "No domain bound", + "pagesProjectId": "Pages project #{id}", + "pagesUnbound": "Pages project not bound", + "tunnelTargetUnset": "Target not configured", + "upstreamUnset": "No upstream configured", + "domainSettings": "Domain settings", + "domainSettingsDesc": "Bind FQDNs already registered in a Zone. Maintain certificates in Zone domain management.", + "redirectNeedsCert": "Bind a certificate to the selected domains (in the Zone) before enabling HTTP redirect", + "domainSettingsSaved": "Domain settings saved", + "redirectHttp": "Automatically redirect HTTP to HTTPS", + "redirectHttpEnabledHint": "When enabled, extra port-80 redirect rules are generated.", + "redirectHttpNeedsCert": "At least one selected domain must have a bound certificate before this can be enabled.", + "reverseProxy": "Reverse proxy", + "reverseProxyDesc": "Configure the origin strategy and upstream addresses.", + "proxySaved": "Reverse proxy settings saved", + "originHostHint": "If left empty, the request hostname $host is passed through by default.", + "customHeaders": "Custom request headers", + "customHeadersHint": "One per line, in Key: Value format.", + "limits": "Traffic limits", + "limitsDesc": "Site rate limits. Empty or 0 inherits the global default; -1 turns it off explicitly; greater than 0 is custom.", + "limitsSaved": "Traffic limits saved", + "connLimit": "Concurrency limit", + "connLimitHint": "Empty or 0 inherits the global default; -1 turns it off; greater than 0 is a custom concurrency cap.", + "ipLimit": "Per-IP limit", + "ipLimitHint": "Empty or 0 inherits the global default; -1 turns it off; greater than 0 is a custom per-IP cap.", + "rateLimit": "Rate limit", + "rateLimitPlaceholder": "512k/1m or -1", + "rateLimitHint": "Empty or 0 inherits the global default; -1 turns it off; for example 512k or 1m is a custom bandwidth.", + "reqLimit": "Per-IP request rate", + "reqLimitPlaceholder": "10r/s / 100r/m or -1", + "reqLimitHint": "Empty or 0 inherits the global default; -1 turns it off; for example 10r/s or 100r/m is a custom rate.", + "cache": "Cache", + "cacheDesc": "Configure the site edge cache policy.", + "cacheSaved": "Cache settings saved", + "enableCache": "Enable site cache", + "enableCacheDesc": "New sites should use “Standard static assets”.", + "cachePolicy": "Cache policy", + "policyStatic": "Standard static assets (recommended)", + "policyAll": "All cacheable GET (advanced)", + "policySuffix": "Custom suffix", + "policyPrefix": "Path prefix", + "policyExact": "Exact path", + "advancedRiskTitle": "Advanced policy risk", + "advancedRiskDesc": "Personalized HTML may be edge-cached and served to other users if the origin does not declare private / no-store. See the help next to the title for details.", + "cacheRules": "Cache rules", + "cacheHelpTitle": "Cache usage notes", + "cacheHelpIntro": "The extension/policy decides whether a response can be cached. Origin headers and Set-Cookie decide whether it is stored. Global OpenResty cache in performance settings must also be enabled.", + "cacheHelpRecommendLabel": "Recommended policy:", + "cacheHelpRecommend": "New sites should use “Standard static assets” (includes css/js/map/images/fonts/media, excludes HTML/JSON).", + "cacheHelpGeneralLabel": "General rules:", + "cacheHelpGeneral": "Non-GET is not cached; login cookies do not skip cache on their own; origin private/no-store or a Set-Cookie response is not written to the edge.", + "cacheHelpAllGetLabel": "All cacheable GET:", + "cacheHelpAllGet": "An advanced option, similar to Cache Everything. Personalized pages must be declared private/no-store by the origin, or HTML may be edge-cached and served to other users.", + "cacheHelpCustomLabel": "Custom rules:", + "cacheHelpCustom": "Use suffixes such as jpg/css/js; path prefixes such as /assets; exact paths such as /robots.txt. After saving, republish a configuration version before it takes effect on nodes.", + "cacheHintSuffix": "One suffix per line, for example jpg, css, js.", + "cacheHintPrefix": "One path prefix per line, for example /assets, /static.", + "cacheHintExact": "One exact path per line, for example /robots.txt.", + "cacheHintStatic": "Standard static assets use the built-in extension list. No extra rules are needed.", + "cacheHintNone": "The current policy does not need extra path rules.", + "cachePlaceholderNone": "No extra rules for the current policy", + "auth": "Authentication", + "authDesc": "Configure Basic Auth to block unauthorized access. Configure PoW protection in a WAF rule group.", + "authSaved": "Authentication settings saved", + "authFormInvalid": "Check the authentication form", + "authMode": "Authentication mode", + "authNone": "No authentication", + "powHint": "PoW protection can only be enabled in a WAF rule group and bound to a site.", + "username": "Username", + "password": "Password", + "wafDesc": "Global rule groups always apply. Here you can add custom rule groups for the current website.", + "wafUpdated": "WAF rule groups updated", + "loadingWaf": "Loading WAF rule groups...", + "alwaysOn": "Always on", + "selectCustomRules": "Select custom rules", + "selectCustomRulesDesc": "Selected rules run in the execution order below.", + "selectRule": "Select {name}", + "ruleEnabled": "Enabled", + "ruleDisabled": "Disabled", + "nodeCount": "{count} nodes", + "executionOrder": "Execution order", + "executionOrderDesc": "Custom rules run in this order. Drag them or use the up/down buttons to change the order.", + "noCustomWaf": "No custom WAF rule groups", + "dragRule": "Drag {name}", + "moveUpRule": "Move {name} up", + "moveDownRule": "Move {name} down", + "sections": { + "domains": "Domain settings", + "domainsDesc": "Maintain the site identifier and choose bound domains from a Zone.", + "limits": "Traffic limits", + "limitsDesc": "Set connection limits and rate limits (can inherit global defaults).", + "proxy": "Reverse proxy", + "proxyDesc": "Configure the primary origin and upstream addresses.", + "cache": "Cache", + "cacheDesc": "Configure the site cache policy.", + "waf": "WAF", + "wafDesc": "Bind WAF rule groups and view the policies that apply to this site.", + "auth": "Authentication", + "authDesc": "Configure basic authentication so visitors must enter a username and password." + }, + "validation": { + "enterDomain": "Enter a domain", + "invalidDomain": "Invalid domain format", + "enterAtLeastOneDomain": "Enter at least one domain", + "invalidDomainWithValue": "Invalid domain format: {domain}", + "duplicateDomain": "Duplicate domain: {domain}", + "enterAtLeastOneUpstream": "Enter at least one upstream address", + "invalidUpstream": "Invalid upstream address format: {url}", + "upstreamMustBeHttp": "Upstream address must start with http:// or https://: {url}", + "upstreamMissingHost": "Upstream address is missing a hostname: {url}", + "multiUpstreamNoPath": "Multi-upstream mode does not support addresses with a path or query", + "multiUpstreamSameProtocol": "Multiple upstreams for the same site must use the same protocol", + "invalidOriginHost": "Invalid origin Host format", + "invalidHeader": "Invalid custom request header format: {line}", + "invalidHeaderName": "Invalid custom request header name: {key}", + "invalidLimitRate": "Invalid rate-limit format. Use 512k, 1m, a number, or -1 to turn it off", + "invalidLimitReq": "Invalid request-rate format. Use 10r/s, 100r/m, or -1 to turn it off", + "cacheRuleRequired": "The current cache policy needs at least one rule", + "invalidCacheSuffix": "Invalid cache suffix format: {rule}", + "invalidCachePath": "Invalid cache path rule format: {rule}", + "siteNameTooLong": "Site identifier cannot exceed 255 characters", + "selectAtLeastOneDomain": "Select at least one domain", + "selectTunnel": "Select a tunnel", + "enterTunnelTarget": "Enter an internal service address (for example 127.0.0.1:8080)", + "selectPagesProject": "Select a Pages project", + "enterSiteId": "Enter a site identifier", + "enterIntegerOrMinusOne": "Enter -1, 0, or a positive integer", + "enterUsername": "Enter a username", + "enterPassword": "Enter a password" + } + }, + "responses": { + "title": "Response pages", + "subtitle": "Configure origin error pages and offline pages.", + "loadingPermission": "Loading permission information...", + "loadingConfig": "Loading response page settings...", + "loadingPage": "Loading response pages...", + "loadingErrorConfig": "Loading error page settings...", + "loadingOfflineConfig": "Loading offline page settings...", + "loadFailed": "Load failed", + "forbidden": "Insufficient permission", + "forbiddenSettings": "Only administrators can access response page settings.", + "forbiddenPreviewError": "Only administrators can preview the error page.", + "forbiddenPreviewOffline": "Only administrators can preview the offline page.", + "forbiddenEditError": "Only administrators can edit the error page HTML.", + "forbiddenEditOffline": "Only administrators can edit the offline page HTML.", + "errorPage": "Error page", + "offlinePage": "Offline page", + "saveFailed": "Save failed", + "policySaved": "Trigger policy saved. Publish a version for the configuration to take effect.", + "triggerPolicy": "Trigger policy", + "triggerPolicyDesc": "Configure when the origin error page is enabled and which status codes trigger it.", + "enableErrorPage": "Enable origin error page", + "enableErrorPageDesc": "When disabled, the origin or Nginx default error response is passed through.", + "getOnly": "GET requests only", + "getOnlyDesc": "When enabled, the custom error page is returned only for matching error status codes on GET requests.", + "statusCodes": "Trigger status codes", + "statusCodesDesc": "Supports a single code (such as 502) or a closed range (such as 500-599).", + "statusCodesPlaceholder": "For example 502 or 500-599, then press Enter", + "pagePreview": "Page preview", + "preview": "Preview", + "edit": "Edit", + "errorPagePreview": "Origin error page preview", + "errorPreview": "Error page preview", + "offlinePreview": "Offline page preview", + "offlinePageDesc": "When enabled, a Service Worker is issued to HTTPS websites. If the domain is unreachable, the browser shows this offline page from cache.", + "offlineSaved": "Offline page saved. Publish a version for the configuration to take effect.", + "enableOffline": "Enable Service Worker offline page", + "enableOfflineDesc": "Only takes effect on HTTPS websites.", + "enableOfflineAria": "Enable offline page", + "scope": "Scope", + "scopeDesc": "Applies only to the selected HTTPS domains. If empty, nothing is injected.", + "addDomain": "Add domain", + "noScopeSelected": "No scope domains selected. After saving, nothing is injected into any site.", + "enableThenSelectScope": "Enable the offline fallback before choosing scope domains.", + "removeDomain": "Remove {domain}", + "selectScopeTitle": "Select scope domains", + "selectScopeDesc": "Inject the offline fallback only for selected HTTPS domains. Search, filter, and bulk-select.", + "domain": "Domain", + "searchDomainOrZone": "Search domain or apex…", + "selectedCount": "Selected {count}", + "visibleCount": "Visible {count}", + "unselectVisible": "Clear visible", + "selectVisible": "Select visible", + "clear": "Clear", + "noAvailableDomains": "No domains available. Register domains in Zone management first.", + "noMatchingDomains": "No matching domains", + "selectZone": "Select apex {domain}", + "apex": "Apex", + "confirm": "OK", + "backToError": "Back to error page", + "backToOffline": "Back to offline page", + "closePreview": "Close preview", + "fullscreen": "Full-screen view", + "htmlTooLarge": "HTML exceeds the size limit (max 256KB)", + "errorHtmlSaved": "Origin error page HTML saved. Publish a version for the configuration to take effect.", + "offlineHtmlSaved": "Offline page HTML saved. Publish a version for the configuration to take effect.", + "templateNotFound": "Selected template not found", + "templateLoaded": "Loaded template “{name}” into the editor", + "errorRestored": "Cleared to use the server built-in default template (save to apply)", + "offlineRestored": "Cleared to use the built-in default template (save to apply)", + "editErrorHtml": "Edit error page HTML", + "editOfflineHtml": "Edit offline page HTML", + "offlineLivePreview": "Offline page live preview", + "builtinTemplates": "Built-in templates", + "selectTemplate": "Select a template", + "loadTemplate": "Load template", + "restoreDefault": "Restore default", + "restoreTitle": "Restore default HTML?", + "restoreErrorDesc": "This clears the editor. After saving, the server built-in default template is used. This action does not save automatically.", + "restoreOfflineDesc": "This clears the editor. After saving, the built-in default template is used. This action does not save automatically.", + "confirmRestore": "Restore" + } +} diff --git a/frontend/messages/fragments/websites.zh-CN.json b/frontend/messages/fragments/websites.zh-CN.json new file mode 100644 index 00000000..660af003 --- /dev/null +++ b/frontend/messages/fragments/websites.zh-CN.json @@ -0,0 +1,582 @@ +{ + "websites": { + "title": "网站", + "createZone": "新增 Zone", + "searchRoot": "搜索 Zone 根域", + "loadingList": "加载 Zone 列表中...", + "requestFailed": "请求失败,请稍后重试。", + "emptySearch": "未找到匹配的 Zone。", + "emptyList": "暂无 Zone,点击右上角「新增 Zone」开始录入。", + "rootDomain": "根域", + "domainCount": "域名数", + "createdAt": "创建时间", + "updatedAt": "更新时间", + "actions": "操作", + "manage": "管理", + "manageZone": "管理 Zone", + "loadingDetail": "加载 Zone 详情中...", + "invalidId": "无效的网站 ID,请从网站列表进入详情页。", + "notFound": "网站不存在,可能已被删除或 ID 无效。", + "back": "返回", + "detailSubtitle": "Zone 网站管理", + "tabOverview": "概览", + "tabDomains": "域名 ({count})", + "tabCertificates": "证书 ({count})", + "basicInfo": "基本信息", + "basicInfoDesc": "维护 Zone 的可注册根域。根域变更后,请确认其下域名仍归属该根域。", + "currentRoot": "当前根域", + "editRoot": "编辑根域", + "dangerZone": "危险操作", + "dangerZoneDesc": "删除 Zone 前须清空其下全部域名;删除后不可恢复。", + "deleteZone": "删除 Zone", + "deleteZoneTitle": "确认删除 Zone", + "deleteZoneDesc": "确认删除 {domain} 吗?其下域名须先解绑路由后才能删除,此操作不可撤销。", + "deleting": "删除中…", + "confirmDelete": "确认删除", + "zoneDeleted": "Zone 已删除", + "editZone": "编辑 Zone", + "editorDesc": "Zone 仅接受可注册根域,例如 example.com。", + "rootRequired": "请输入 Zone 根域", + "rootInvalid": "请输入不含协议或通配符的根域", + "zoneUpdated": "Zone 已更新", + "zoneCreated": "Zone 已创建", + "saveFailed": "保存失败", + "saveChanges": "保存修改", + "range24h": "24 小时", + "range7d": "7 天", + "range30d": "30 天", + "chartUv": "分桶 UV", + "chartBytes": "已提供数据", + "statsFailed": "加载流量统计失败", + "analyticsUnavailable": "分析存储暂不可用,图表可能为空。请确认 ClickHouse 已启用。", + "uniqueVisitors": "查询窗口独立访客", + "uniqueVisitorsDesc": "顶部合计为整窗去重;曲线为各时间桶内 UV,桶间不可相加", + "totalRequests": "请求总数", + "totalBytes": "已提供的数据总计", + "zoneInfo": "Zone 信息", + "addDomain": "添加域名", + "emptyDomains": "暂无已添加域名", + "certificate": "证书", + "upstream": "上游", + "certNumber": "证书 #{id}", + "unbound": "未绑定", + "unlinkedRoute": "未关联路由", + "loadingEllipsis": "加载中…", + "upstreamUnset": "未配置上游", + "viewRoute": "查看路由详情", + "deleteDomain": "删除域名", + "domainDeleted": "域名已删除", + "deleteFailed": "删除失败", + "deleteDomainTitle": "确认删除域名", + "deleteDomainDesc": "确认删除 {domain} 吗?此操作不可撤销。", + "zoneCertsTitle": "本 Zone 使用的证书", + "zoneCertsDesc": "证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。", + "certLibrary": "证书库", + "unboundCertHint": "还有 {count} 个域名未绑定证书,可在「域名」Tab 中选择证书。", + "emptyBoundCerts": "暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。", + "status": "状态", + "coverage": "覆盖域名", + "expiresAt": "到期时间", + "selectZone": "请先选择 Zone", + "enterDomain": "请输入域名", + "noWildcard": "Zone 域名不支持通配符", + "invalidFormat": "域名格式不合法", + "invalidSubdomain": "子域名称格式不合法", + "mustBelongToZone": "域名必须属于 Zone {root}", + "domainAdded": "域名已添加", + "addFailed": "添加失败", + "quickCreateTitle": "快捷新增域名", + "quickCreateDesc": "选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。", + "selectRegisteredRoot": "选择注册根域", + "zoneLabel": "Zone:", + "domain": "域名", + "domainPlaceholderWithZone": "api 或 @ 或 api.{domain}", + "domainPlaceholder": "api / @ / 完整域名", + "willCreate": "将创建:", + "domainExample": "示例:输入 api → api.zone.com;@ → 根域本身", + "certOptional": "证书(可选)", + "noCert": "不绑定证书" + }, + "certificates": { + "title": "证书", + "refresh": "刷新证书", + "importCert": "导入证书", + "applyTitle": "申请证书", + "listTitle": "证书列表", + "listDesc": "展示证书有效期、备注和状态,支持查看详情、编辑内容或删除证书。", + "loadingList": "加载证书列表中...", + "requestFailed": "请求失败,请稍后重试。", + "emptyList": "暂无证书,点击右上角「导入证书」或「申请证书」开始录入。", + "notBeforeLabel": "生效:{value}", + "notAfterLabel": "到期:{value}", + "sourceLabel": "来源:{value}", + "sourceAcme": "ACME 申请", + "sourceUpload": "手动上传", + "statusLabel": "状态:{value}", + "converting": "转换申请中...", + "applying": "申请中...", + "statusError": "状态:{value}({message})", + "convertFailed": "转换失败", + "applyFailed": "申请失败", + "remarkLabel": "备注:{value}", + "noRemark": "暂无备注", + "view": "查看", + "edit": "编辑", + "renew": "续期", + "deleted": "证书已删除", + "renewSubmitted": "证书 {name} 续期任务已提交", + "imported": "证书 {name} 已导入", + "applySubmitted": "证书 {name} 申请任务已提交", + "convertSubmitted": "证书 {name} 转换申请已提交", + "reapplySubmitted": "证书 {name} 配置已更新,重新申请中...", + "updated": "证书 {name} 已更新", + "deleteTitle": "确认删除证书", + "deleteDesc": "确认删除证书 {name} 吗?", + "applyEditTitle": "编辑并重新申请证书", + "applyConvertTitle": "转换为申请证书", + "applyEditDesc": "修改 ACME 证书配置。保存后将使用新配置重新申请证书。", + "applyConvertDesc": "填写 ACME 申请资料。申请成功后,当前手动证书会原地转换为可自动续签的申请证书。", + "applyDesc": "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。", + "name": "证书名称", + "namePlaceholder": "例如:主站证书", + "primaryDomain": "主域名", + "primaryDomainPlaceholder": "example.com 或 *.example.com", + "otherDomains": "其他域名", + "otherDomainsHint": "每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。", + "dnsAccount": "DNS 服务商账号", + "selectDnsAccount": "请选择 DNS 账号", + "keyAlgorithm": "密钥算法", + "remark": "备注", + "remarkPlaceholder": "可选,用于记录证书用途。", + "autoRenew": "开启自动续签", + "autoRenewDesc": "开启后,将在证书过期前 7 天自动续期。", + "advanced": "高级选项", + "dnsServer1": "DNS 验证服务器 1", + "dnsServer2": "DNS 验证服务器 2", + "dnsServerPlaceholder": "为空则使用默认权威 DNS", + "skipCname": "跳过 CNAME 检查", + "skipCnameDesc": "在执行 DNS-01 验证时不追踪 CNAME 记录。", + "skipDns": "跳过 DNS 前置检查", + "skipDnsDesc": "直接请求 Let's Encrypt 验证而不做本地校验。", + "submitting": "提交中...", + "saveAndApply": "保存并申请", + "startConvert": "开始转换", + "startApply": "开始申请", + "editTitle": "编辑证书", + "editDesc": "可以修改证书名称、备注,以及重新上传 PEM 证书和私钥内容。", + "loadingContent": "加载证书内容中...", + "notFound": "证书不存在,可能已被删除。", + "certPem": "证书 PEM", + "keyPem": "私钥 PEM", + "convertSource": "转换来源", + "saving": "保存中...", + "saveCert": "保存证书", + "importTitle": "添加证书", + "importDesc": "支持手动粘贴 PEM 或上传证书文件。导入成功后可立即在网站表单里选择。", + "manualImport": "手动导入", + "fileImport": "文件导入", + "remarkProdPlaceholder": "例如:主站生产证书", + "importing": "导入中...", + "remarkWildcardPlaceholder": "例如:泛域名生产证书", + "certFile": "证书文件", + "keyFile": "私钥文件", + "fileSelected": "已选择:{name}", + "selectCertFile": "请选择 PEM/CRT 文件", + "selectKeyFile": "请选择 KEY/PEM 文件", + "uploading": "上传中...", + "uploadFiles": "上传文件", + "clearFiles": "清空文件", + "selectFiles": "请选择证书文件和私钥文件。", + "detailTitle": "证书详情", + "detailDesc": "查看证书元信息、备注以及当前保存的 PEM 内容。", + "loadingDetail": "加载证书详情中...", + "status": "状态", + "notBefore": "生效时间", + "notAfter": "到期时间", + "certPemCopied": "证书 PEM 已复制", + "keyPemCopied": "私钥 PEM 已复制", + "copy": "复制", + "close": "关闭", + "editCert": "编辑证书", + "deleting": "删除中...", + "deleteCert": "删除证书", + "certStatus": { + "unknownExpiry": "有效期未知", + "expired": "已过期", + "expiresInDays": "{days} 天内到期", + "valid": "有效" + }, + "validation": { + "nameRequired": "请输入证书名称", + "nameTooLong": "证书名称不能超过 255 个字符", + "certPemRequired": "请输入证书 PEM 内容", + "keyPemRequired": "请输入私钥 PEM 内容", + "remarkTooLong": "备注不能超过 255 个字符", + "primaryDomainRequired": "请输入主域名", + "dnsAccountRequired": "请选择 DNS 账号" + } + }, + "dnsAccounts": { + "title": "DNS 账号", + "addAccount": "添加账号", + "listTitle": "DNS 账号列表", + "listDesc": "统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。", + "loadingList": "加载 DNS 账号中...", + "requestFailed": "请求失败,请稍后重试。", + "emptyList": "暂无 DNS 账号,点击右上角「添加账号」开始录入。", + "createdAt": "创建于:{date}", + "created": "DNS 账号已添加", + "deleted": "DNS 账号已删除", + "deleteTitle": "确认删除 DNS 账号", + "deleteDesc": "确认删除 DNS 账号 {name} 吗?", + "createTitle": "添加 DNS 账号", + "createDesc": "统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。", + "accountName": "账号名称", + "accountNamePlaceholder": "Cloudflare 邮箱账号", + "provider": "DNS 服务商", + "tokenPlaceholder": "请勿使用 Global API Key", + "nameRequired": "请输入名称", + "tokenRequired": "请输入 Token", + "submitting": "提交中...", + "submit": "提交" + }, + "origins": { + "title": "源站", + "subtitle": "集中维护规则复用的源站地址,减少批量改地址时的重复操作。", + "refresh": "刷新", + "create": "新增源站", + "loadFailed": "加载失败", + "emptyTitle": "暂无源站", + "emptyDesc": "点击右上角新增源站,后续规则可直接复用这些地址。", + "routeCount": "{count} 条规则", + "noRemark": "暂无备注", + "lastUpdated": "最后更新:{date}", + "detail": "详情", + "edit": "编辑", + "deleted": "源站已删除", + "deleteFailed": "删除失败", + "deleteTitle": "删除源站", + "deleteDesc": "确认删除源站 {name} 吗?", + "confirmDelete": "确认删除", + "missingId": "缺少有效的源站 ID。", + "loadingDetail": "加载源站详情...", + "backToList": "返回列表", + "notFound": "源站不存在或已被删除。", + "editOrigin": "编辑源站", + "deleteOrigin": "删除源站", + "boundRoutes": "绑定规则", + "createdAt": "创建时间", + "updatedAt": "更新时间", + "remark": "备注", + "relatedRoutes": "关联规则", + "relatedRoutesDesc": "展示当前源站作为主源站绑定的规则。", + "noRelatedRoutes": "暂无关联规则", + "noRelatedRoutesDesc": "当前源站还没有被任何规则引用。", + "domain": "域名", + "address": "源站地址", + "status": "状态", + "enabled": "启用", + "disabled": "停用", + "addressRequired": "请输入源站地址", + "addressInvalid": "源站地址格式不合法", + "updated": "源站已更新", + "created": "源站已创建", + "saveFailed": "保存失败", + "editorDesc": "源站作为规则里的可复用地址目录,协议和端口仍由规则决定。", + "name": "源站名", + "namePlaceholder": "主站源站", + "saving": "保存中...", + "saveChanges": "保存修改" + }, + "proxyRoutes": { + "title": "规则配置", + "refresh": "刷新", + "createRule": "新建规则", + "listTitle": "规则列表", + "searchPlaceholder": "搜索站点、域名或上游...", + "emptyTitle": "暂无网站", + "emptyDesc": "先创建一个网站,再进入配置子页面继续补齐 HTTPS、缓存和限流。", + "noMatches": "没有匹配结果", + "noMatchesDesc": "试试调整搜索词,或者清空筛选条件。", + "siteName": "站点名称", + "domain": "域名", + "status": "状态", + "upstream": "上游", + "actions": "操作", + "enabled": "已启用", + "disabled": "已停用", + "configure": "配置", + "siteCreated": "网站已创建", + "siteDeleted": "网站已删除", + "deleteFailed": "删除失败", + "loadListFailed": "规则列表加载失败", + "unknownError": "未知错误", + "deleteSiteTitle": "确认删除网站", + "deleteSiteDesc": "确认删除网站 {name} 吗?这会删除该站点下的全部域名与配置。", + "deleting": "删除中...", + "missingId": "缺少有效的规则 ID。", + "notFound": "未找到对应规则", + "notFoundDesc": "请返回列表重试。", + "saving": "保存中...", + "saveFailed": "保存失败", + "requestFailed": "请求失败,请稍后重试。", + "back": "返回", + "publish": "发布配置", + "diffFailed": "获取配置差异失败", + "noPublishNeeded": "当前配置与激活版本一致,无需发布", + "publishSuccess": "发布成功", + "versionLabel": "版本 {version}", + "publishFailed": "发布失败", + "publishTitle": "确认发布配置", + "publishDesc": "将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。", + "confirmPublish": "确认发布", + "createDesc": "从已注册的 Zone 域名中选择绑定关系,创建后可继续配置缓存和限流等高级选项。", + "siteId": "站点标识", + "siteIdOptionalDesc": "可选,留空时会自动使用首个域名。", + "siteIdStableDesc": "建议使用稳定、可读的业务标识,不必与域名完全一致。", + "bindDomains": "绑定域名", + "bindDomainsHint": "从已登记域名中勾选绑定;可用「快捷新增域名」在 Zone 下创建 FQDN 并自动勾选。", + "upstreamType": "回源方式", + "upstreamDirect": "直连上游", + "upstreamTunnel": "内网穿透 (Tunnel)", + "upstreamPages": "Pages 静态站点", + "upstreamAddresses": "上游地址", + "upstreamUrlsHint": "每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。", + "selectTunnel": "选择内网穿透隧道", + "pleaseSelect": "请选择...", + "online": "在线", + "offline": "离线", + "tunnelForwardHint": "将请求转发到该隧道连接的客户端节点。", + "tunnelProtocol": "内网服务协议", + "tunnelAddress": "内网服务地址", + "tunnelAddressHint": "例如: 127.0.0.1:8080 或 192.168.1.10:80", + "selectPagesProject": "选择 Pages 项目", + "pagesProjectHint": "仅显示已启用且已有激活部署的 Pages 项目。", + "enableSite": "启用站点", + "enableSiteDesc": "关闭后会保留配置,但不会参与发布。", + "createFailed": "创建失败,请稍后重试", + "creating": "创建中…", + "create": "创建", + "searchDomainOrZone": "搜索域名或 Zone…", + "quickAddDomain": "快捷新增域名", + "noZoneDomains": "暂无可用 Zone 域名。请先在 网站管理 中添加 FQDN,或使用「快捷新增域名」。", + "noBindableDomains": "没有可绑定的域名(其余域名已绑定其他路由)。可用「快捷新增域名」创建。", + "noMatchingDomains": "没有匹配的域名", + "certNumber": "证书 #{id}", + "noCert": "无证书", + "zoneName": "Zone {name}", + "zoneId": "Zone #{id}", + "unboundDomain": "未绑定域名", + "pagesProjectId": "Pages 项目 #{id}", + "pagesUnbound": "Pages 项目未绑定", + "tunnelTargetUnset": "未配置目标", + "upstreamUnset": "未配置上游", + "domainSettings": "域名设置", + "domainSettingsDesc": "绑定已在 Zone 中注册的 FQDN。证书请在 Zone 域名管理中维护。", + "redirectNeedsCert": "启用 HTTP 跳转前,请先为所选域名绑定证书(在 Zone 中配置)", + "domainSettingsSaved": "域名设置已保存", + "redirectHttp": "HTTP 自动跳转到 HTTPS", + "redirectHttpEnabledHint": "开启后会额外生成 80 端口重定向规则。", + "redirectHttpNeedsCert": "所选域名至少绑定一张证书后才能启用。", + "reverseProxy": "反向代理", + "reverseProxyDesc": "配置请求回源上游的策略与地址。", + "proxySaved": "反向代理设置已保存", + "originHostHint": "留空时默认透传访问域名 $host。", + "customHeaders": "自定义请求头", + "customHeadersHint": "每行一条,格式为 Key: Value。", + "limits": "流量限制", + "limitsDesc": "站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。", + "limitsSaved": "流量限制已保存", + "connLimit": "并发限制", + "connLimitHint": "空或 0 继承全局默认;-1 关闭;大于 0 为自定义并发上限。", + "ipLimit": "单 IP 限制", + "ipLimitHint": "空或 0 继承全局默认;-1 关闭;大于 0 为单 IP 自定义上限。", + "rateLimit": "限速", + "rateLimitPlaceholder": "512k/1m 或 -1", + "rateLimitHint": "空或 0 继承全局默认;-1 关闭;例如 512k、1m 为自定义带宽。", + "reqLimit": "单 IP 请求频率", + "reqLimitPlaceholder": "10r/s / 100r/m 或 -1", + "reqLimitHint": "空或 0 继承全局默认;-1 关闭;例如 10r/s、100r/m 为自定义频率。", + "cache": "缓存", + "cacheDesc": "配置站点边缘缓存策略。", + "cacheSaved": "缓存设置已保存", + "enableCache": "启用站点缓存", + "enableCacheDesc": "新建推荐「标准静态资源」。", + "cachePolicy": "缓存策略", + "policyStatic": "标准静态资源(推荐)", + "policyAll": "所有可缓存 GET(高级)", + "policySuffix": "自定义后缀", + "policyPrefix": "路径前缀", + "policyExact": "精确路径", + "advancedRiskTitle": "高级策略风险", + "advancedRiskDesc": "个性化 HTML 在源站未声明 private / no-store 时可能被边缘缓存并串给其他用户。详情见标题旁帮助。", + "cacheRules": "缓存规则", + "cacheHelpTitle": "缓存使用说明", + "cacheHelpIntro": "扩展名/策略决定是否可缓存,源站头与Set-Cookie 决定是否入库。须同时开启性能设置中的全局 OpenResty 缓存。", + "cacheHelpRecommendLabel": "推荐策略:", + "cacheHelpRecommend": "新建站点建议使用「标准静态资源」(含 css/js/map/图片/字体/媒体等,不含 HTML/JSON)。", + "cacheHelpGeneralLabel": "通用规则:", + "cacheHelpGeneral": "非 GET 不缓存;登录 Cookie 不会单独跳过缓存;源站 private/no-store 或响应 Set-Cookie 不会写入边缘。", + "cacheHelpAllGetLabel": "所有可缓存 GET:", + "cacheHelpAllGet": "高级选项,类似 Cache Everything。个性化页面须由源站声明 private/no-store,否则 HTML 可能被边缘缓存并串给其他用户。", + "cacheHelpCustomLabel": "自定义规则:", + "cacheHelpCustom": "后缀填 jpg/css/js;路径前缀填 /assets;精确路径填 /robots.txt。保存后须重新发布配置版本才会在节点生效。", + "cacheHintSuffix": "每行一个后缀,例如 jpg、css、js。", + "cacheHintPrefix": "每行一个路径前缀,例如 /assets、/static。", + "cacheHintExact": "每行一个精确路径,例如 /robots.txt。", + "cacheHintStatic": "标准静态资源使用内置扩展名列表,无需填写规则。", + "cacheHintNone": "当前策略无需额外路径规则。", + "cachePlaceholderNone": "当前策略无需额外规则", + "auth": "认证配置", + "authDesc": "配置 Basic Auth 限制未授权访问。PoW 防护请在 WAF 规则组中配置。", + "authSaved": "认证配置已保存", + "authFormInvalid": "请检查认证配置表单", + "authMode": "认证模式", + "authNone": "无认证", + "powHint": "PoW 防护仅支持在 WAF 规则组中启用并绑定站点。", + "username": "账号", + "password": "密码", + "wafDesc": "全局规则组始终生效;这里可以为当前网站叠加自定义规则组。", + "wafUpdated": "WAF 规则组已更新", + "loadingWaf": "加载 WAF 规则组...", + "alwaysOn": "始终生效", + "selectCustomRules": "选择自定义规则", + "selectCustomRulesDesc": "选中的规则会按下方执行顺序依次运行。", + "selectRule": "选择{name}", + "ruleEnabled": "启用中", + "ruleDisabled": "已停用", + "nodeCount": "{count} 个节点", + "executionOrder": "执行顺序", + "executionOrderDesc": "自定义规则按此顺序执行,可拖动或使用上下移动按钮调整。", + "noCustomWaf": "暂无自定义 WAF 规则组", + "dragRule": "拖动{name}", + "moveUpRule": "上移{name}", + "moveDownRule": "下移{name}", + "sections": { + "domains": "域名设置", + "domainsDesc": "维护站点标识,并从 Zone 中选择绑定域名。", + "limits": "流量限制", + "limitsDesc": "设置连接数和限速(可继承全局默认)。", + "proxy": "反向代理", + "proxyDesc": "配置主回源和上游地址。", + "cache": "缓存", + "cacheDesc": "配置站点缓存策略。", + "waf": "WAF", + "wafDesc": "绑定 WAF 规则组,并查看当前站点生效策略。", + "auth": "认证配置", + "authDesc": "配置基础鉴权访问,需要输入账号密码才能访问网站。" + }, + "validation": { + "enterDomain": "请输入域名", + "invalidDomain": "域名格式不合法", + "enterAtLeastOneDomain": "请至少填写一个域名", + "invalidDomainWithValue": "域名格式不合法:{domain}", + "duplicateDomain": "域名重复:{domain}", + "enterAtLeastOneUpstream": "请至少填写一个上游地址", + "invalidUpstream": "上游地址格式不合法:{url}", + "upstreamMustBeHttp": "上游地址必须以 http:// 或 https:// 开头:{url}", + "upstreamMissingHost": "上游地址缺少主机名:{url}", + "multiUpstreamNoPath": "多上游模式暂不支持带路径或查询参数的地址", + "multiUpstreamSameProtocol": "同一站点的多个上游必须使用相同协议", + "invalidOriginHost": "回源 Host 格式不合法", + "invalidHeader": "自定义请求头格式不合法:{line}", + "invalidHeaderName": "自定义请求头名称不合法:{key}", + "invalidLimitRate": "限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭", + "invalidLimitReq": "请求频率格式不合法,请使用 10r/s、100r/m,或 -1 关闭", + "cacheRuleRequired": "当前缓存策略至少需要一条规则", + "invalidCacheSuffix": "缓存后缀格式不合法:{rule}", + "invalidCachePath": "缓存路径规则格式不合法:{rule}", + "siteNameTooLong": "站点标识不能超过 255 个字符", + "selectAtLeastOneDomain": "请至少选择一个域名", + "selectTunnel": "请选择内网穿透隧道", + "enterTunnelTarget": "请填写内网服务地址 (如 127.0.0.1:8080)", + "selectPagesProject": "请选择 Pages 项目", + "enterSiteId": "请输入站点标识", + "enterIntegerOrMinusOne": "请输入 -1、0 或正整数", + "enterUsername": "请输入账号", + "enterPassword": "请输入密码" + } + }, + "responses": { + "title": "响应页面", + "subtitle": "配置源站错误页与离线页。", + "loadingPermission": "加载权限信息...", + "loadingConfig": "加载响应页面配置...", + "loadingPage": "加载响应页面...", + "loadingErrorConfig": "加载错误页配置...", + "loadingOfflineConfig": "加载离线页配置...", + "loadFailed": "加载失败", + "forbidden": "权限不足", + "forbiddenSettings": "只有管理员可以访问响应页面设置。", + "forbiddenPreviewError": "只有管理员可以预览错误页。", + "forbiddenPreviewOffline": "只有管理员可以预览离线页。", + "forbiddenEditError": "只有管理员可以编辑错误页 HTML。", + "forbiddenEditOffline": "只有管理员可以编辑离线页 HTML。", + "errorPage": "错误页", + "offlinePage": "离线页", + "saveFailed": "保存失败", + "policySaved": "触发策略已保存,请前往版本发布使配置生效", + "triggerPolicy": "触发策略", + "triggerPolicyDesc": "配置源站错误页的启用条件与触发状态码。", + "enableErrorPage": "启用源站错误页", + "enableErrorPageDesc": "关闭后会透传源站或 Nginx 默认错误响应。", + "getOnly": "仅针对 GET 请求", + "getOnlyDesc": "开启后仅对 GET 请求的匹配错误状态码返回自定义错误页。", + "statusCodes": "触发状态码", + "statusCodesDesc": "支持单码(如 502)或闭区间(如 500-599)。", + "statusCodesPlaceholder": "例如 502 或 500-599,回车添加", + "pagePreview": "页面预览", + "preview": "预览", + "edit": "编辑", + "errorPagePreview": "源站错误页预览", + "errorPreview": "错误页预览", + "offlinePreview": "离线页预览", + "offlinePageDesc": "启用后给启用 HTTPS 的网站下发 Service Worker,域名无法访问时浏览器从缓存展示此离线页。", + "offlineSaved": "离线页已保存,请前往版本发布使配置生效", + "enableOffline": "启用 Service Worker 离线页", + "enableOfflineDesc": "仅对 HTTPS 网站生效。", + "enableOfflineAria": "启用离线页", + "scope": "生效范围", + "scopeDesc": "仅对选中的 HTTPS 域名生效;留空则不注入。", + "addDomain": "添加域名", + "noScopeSelected": "尚未选择生效域名,保存后不注入任何站点。", + "enableThenSelectScope": "启用离线兜底后可选择生效域名。", + "removeDomain": "移除 {domain}", + "selectScopeTitle": "选择生效域名", + "selectScopeDesc": "仅对选中的 HTTPS 域名注入离线兜底;搜索筛选与批量勾选。", + "domain": "域名", + "searchDomainOrZone": "搜索域名或顶级域…", + "selectedCount": "已选 {count}", + "visibleCount": "可见 {count}", + "unselectVisible": "取消全选", + "selectVisible": "全选可见", + "clear": "清空", + "noAvailableDomains": "暂无可用域名,请先在 Zone 管理中注册域名。", + "noMatchingDomains": "没有匹配的域名", + "selectZone": "选择顶级域 {domain}", + "apex": "顶级域", + "confirm": "确定", + "backToError": "返回错误页", + "backToOffline": "返回离线页", + "closePreview": "关闭预览", + "fullscreen": "全屏展示", + "htmlTooLarge": "HTML 大小超出限制(最大 256KB)", + "errorHtmlSaved": "源站错误页 HTML 已保存,请前往版本发布使配置生效", + "offlineHtmlSaved": "离线页 HTML 已保存,请前往版本发布使配置生效", + "templateNotFound": "未找到所选模板", + "templateLoaded": "已加载模板「{name}」到编辑器", + "errorRestored": "已清空为使用服务端内置默认模板(需保存后生效)", + "offlineRestored": "已清空为使用内置默认模板(需保存后生效)", + "editErrorHtml": "编辑错误页 HTML", + "editOfflineHtml": "编辑离线页 HTML", + "offlineLivePreview": "离线页实时预览", + "builtinTemplates": "内置模板", + "selectTemplate": "选择模板", + "loadTemplate": "加载模板", + "restoreDefault": "恢复默认", + "restoreTitle": "恢复默认 HTML?", + "restoreErrorDesc": "将清空编辑器内容,保存后使用服务端内置默认模板。此操作不会自动保存。", + "restoreOfflineDesc": "将清空编辑器内容,保存后使用内置默认模板。此操作不会自动保存。", + "confirmRestore": "确认恢复" + } +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 06b416a7..8bb486ec 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,13 +9,26 @@ const nextConfig: NextConfig = { reactCompiler: true, // Prevent 308 redirects on /api/* trailing slashes; dev rewrites proxy legacy APIs as-is. skipTrailingSlashRedirect: true, - experimental: {}, + experimental: { + turbopackFileSystemCacheForBuild: true, + }, ...(isExport ? { output: 'export' } : { + async redirects() { + return [ + { source: '/openflare', destination: '/', permanent: true }, + { + source: '/openflare/:path*', + destination: '/:path*', + permanent: true, + }, + { source: '/home', destination: '/', permanent: true }, + ]; + }, async rewrites() { const backendUrl = - process.env.WAVELET_BACKEND_URL || 'http://localhost:8000'; + process.env.WAVELET_BACKEND_URL || 'http://localhost:3000'; return [ // 上传文件静态资源 { diff --git a/frontend/package.json b/frontend/package.json index d7036cc1..b543ffb9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,96 +1,106 @@ { - "name": "wavelet", + "name": "openflare", "version": "dev", "private": true, "scripts": { - "predev": "node scripts/generate-themes.js", - "prebuild": "node scripts/generate-themes.js", - "dev": "next dev --turbopack", - "build": "next build", - "build:embed": "NEXT_STANDALONE_EXPORT=true next build", + "dev": "next dev --turbopack -p 3001", + "build": "node scripts/generate-themes.js && node scripts/merge-i18n-fragments.mjs && next build", + "build:embed": "node scripts/generate-themes.js && node scripts/merge-i18n-fragments.mjs && NEXT_STANDALONE_EXPORT=true next build", "start": "next start -p 3010", "lint": "eslint", "format": "biome format --write .", - "format:check": "biome format ." + "format:check": "biome format .", + "check:i18n": "node scripts/check-i18n-keys.mjs" }, "dependencies": { + "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-sql": "^6.10.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource/noto-sans-sc": "^5.2.8", - "@hookform/resolvers": "^5.2.2", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-progress": "^1.1.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-toggle": "^1.1.10", - "@radix-ui/react-toggle-group": "^1.1.11", - "@radix-ui/react-tooltip": "^1.2.8", - "@radix-ui/react-visually-hidden": "^1.2.4", - "@tabler/icons-react": "^3.35.0", - "@tanstack/react-query": "^5.101.0", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.16", - "@uiw/react-codemirror": "^4.25.10", - "axios": "^1.15.0", + "@fontsource/noto-sans-sc": "^5.3.0", + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-toggle": "^1.1.18", + "@radix-ui/react-toggle-group": "^1.1.19", + "@radix-ui/react-tooltip": "^1.2.16", + "@radix-ui/react-visually-hidden": "^1.2.11", + "@tabler/icons-react": "^3.46.0", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-table": "^9.0.0", + "@tanstack/react-virtual": "^3.14.9", + "@uiw/react-codemirror": "^4.25.11", + "@xyflow/react": "^12.11.2", + "axios": "^1.19.0", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "date-fns": "^4.1.0", + "country-region-data": "^4.1.0", + "date-fns": "^4.4.0", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "jspdf": "4.2.1", - "lucide-react": "^0.552.0", - "motion": "^12.23.24", - "next": "16.2.7", - "next-intl": "^4.13.3", + "jspdf": "^4.2.1", + "lucide-react": "^1.28.0", + "motion": "^12.43.0", + "next": "16.3.0", + "next-intl": "^4.13.6", "next-themes": "^0.4.6", "pinyin-match": "^1.2.10", - "radix-ui": "^1.4.3", - "react": "19.2.3", - "react-day-picker": "^9.11.1", - "react-dom": "19.2.3", - "react-easy-crop": "^5.5.6", - "react-hook-form": "^7.68.0", - "react-image-crop": "^11.0.10", + "radix-ui": "^1.6.7", + "react": "19.2.8", + "react-day-picker": "^10.0.1", + "react-dom": "19.2.8", + "react-easy-crop": "^6.2.3", + "react-hook-form": "^7.84.0", + "react-image-crop": "^11.1.2", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^16.1.0", - "recharts": "2.15.4", + "react-syntax-highlighter": "^16.1.1", + "recharts": "3.10.1", "rehype-highlight": "^7.0.2", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.6.0", "vaul": "^1.1.2", - "zod": "^4.1.12" + "zod": "^4.4.3" }, "devDependencies": { - "@biomejs/biome": "^2.5.5", - "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", - "@tailwindcss/typography": "^0.5.19", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "15.5.6", - "tailwindcss": "^4", + "@biomejs/biome": "^2.5.7", + "@tailwindcss/postcss": "^4.3.3", + "@tailwindcss/typography": "^0.5.20", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/node": "^26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "axe-core": "^4.13.0", + "eslint": "^9.39.5", + "eslint-config-next": "16.3.0", + "eslint-plugin-unused-imports": "^4.4.1", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", - "typescript": "^5.9.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3d4070a7..6cb89cf0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,105 +8,111 @@ importers: .: dependencies: + '@codemirror/lang-html': + specifier: ^6.4.12 + version: 6.4.12 '@codemirror/lang-sql': specifier: ^6.10.0 version: 6.10.0 '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@dnd-kit/modifiers': specifier: ^9.0.0 - version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@dnd-kit/sortable': specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@dnd-kit/utilities': specifier: ^3.2.2 - version: 3.2.2(react@19.2.3) + version: 3.2.2(react@19.2.8) '@fontsource/noto-sans-sc': - specifier: ^5.2.8 - version: 5.2.8 + specifier: ^5.3.0 + version: 5.3.0 '@hookform/resolvers': - specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.68.0(react@19.2.3)) + specifier: ^5.7.1 + version: 5.7.1(@standard-schema/spec@1.1.0)(react-hook-form@7.84.0(react@19.2.8))(zod@4.4.3) '@radix-ui/react-accordion': - specifier: ^1.2.12 - version: 1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.2.20 + version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-alert-dialog': - specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-avatar': - specifier: ^1.1.11 - version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-checkbox': - specifier: ^1.3.3 - version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-dialog': - specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-dropdown-menu': - specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-label': - specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-popover': - specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-progress': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-scroll-area': - specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-select': - specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-separator': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slider': - specifier: ^1.3.6 - version: 1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': - specifier: ^1.2.4 - version: 1.2.4(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-switch': specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.2.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-checkbox': + specifier: ^1.3.11 + version: 1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.24 + version: 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': + specifier: ^2.1.15 + version: 2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-progress': + specifier: ^1.1.16 + version: 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': + specifier: ^1.2.18 + version: 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-select': + specifier: ^2.3.7 + version: 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slider': + specifier: ^1.4.7 + version: 1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': + specifier: ^1.3.3 + version: 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-switch': + specifier: ^1.3.7 + version: 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-tabs': - specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.1.21 + version: 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-toggle': - specifier: ^1.1.10 - version: 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.1.18 + version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-toggle-group': - specifier: ^1.1.11 - version: 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.1.19 + version: 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-tooltip': - specifier: ^1.2.8 - version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.2.16 + version: 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-visually-hidden': - specifier: ^1.2.4 - version: 1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.2.11 + version: 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tabler/icons-react': - specifier: ^3.35.0 - version: 3.35.0(react@19.2.3) + specifier: ^3.46.0 + version: 3.46.0(react@19.2.8) '@tanstack/react-query': - specifier: ^5.101.0 - version: 5.101.0(react@19.2.3) + specifier: ^5.101.4 + version: 5.101.4(react@19.2.8) '@tanstack/react-table': - specifier: ^8.21.3 - version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^9.0.0 + version: 9.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-virtual': - specifier: ^3.13.16 - version: 3.13.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^3.14.9 + version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@uiw/react-codemirror': - specifier: ^4.25.10 - version: 4.25.10(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^4.25.11 + version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.0)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.8)(codemirror@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@xyflow/react': + specifier: ^12.11.2 + version: 12.11.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) axios: - specifier: ^1.15.0 - version: 1.15.0(debug@4.4.3(supports-color@7.2.0)) + specifier: ^1.19.0 + version: 1.19.0 babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -118,67 +124,76 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - date-fns: + version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + country-region-data: specifier: ^4.1.0 version: 4.1.0 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + echarts: + specifier: ^6.1.0 + version: 6.1.0 + echarts-for-react: + specifier: ^3.0.6 + version: 3.0.6(echarts@6.1.0)(react@19.2.8) embla-carousel-react: specifier: ^8.6.0 - version: 8.6.0(react@19.2.3) + version: 8.6.0(react@19.2.8) input-otp: specifier: ^1.4.2 - version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) jspdf: - specifier: 4.2.1 + specifier: ^4.2.1 version: 4.2.1 lucide-react: - specifier: ^0.552.0 - version: 0.552.0(react@19.2.3) + specifier: ^1.28.0 + version: 1.28.0(react@19.2.8) motion: - specifier: ^12.23.24 - version: 12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^12.43.0 + version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next: - specifier: 16.2.7 - version: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 16.3.0 + version: 16.3.0(@babel/core@7.29.7)(@types/node@26.1.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-intl: - specifier: ^4.13.3 - version: 4.13.3(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + specifier: ^4.13.6 + version: 4.13.6(next@16.3.0(@babel/core@7.29.7)(@types/node@26.1.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@6.0.3) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) pinyin-match: specifier: ^1.2.10 version: 1.2.10 radix-ui: - specifier: ^1.4.3 - version: 1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.6.7 + version: 1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: - specifier: 19.2.3 - version: 19.2.3 + specifier: 19.2.8 + version: 19.2.8 react-day-picker: - specifier: ^9.11.1 - version: 9.11.1(react@19.2.3) + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.18)(react@19.2.8) react-dom: - specifier: 19.2.3 - version: 19.2.3(react@19.2.3) + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) react-easy-crop: - specifier: ^5.5.6 - version: 5.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^6.2.3 + version: 6.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-hook-form: - specifier: ^7.68.0 - version: 7.68.0(react@19.2.3) + specifier: ^7.84.0 + version: 7.84.0(react@19.2.8) react-image-crop: - specifier: ^11.0.10 - version: 11.0.10(react@19.2.3) + specifier: ^11.1.2 + version: 11.1.2(react@19.2.8) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.2)(react@19.2.3)(supports-color@7.2.0) + version: 10.1.0(@types/react@19.2.18)(react@19.2.8) react-syntax-highlighter: - specifier: ^16.1.0 - version: 16.1.0(react@19.2.3) + specifier: ^16.1.1 + version: 16.1.1(react@19.2.8) recharts: - specifier: 2.15.4 - version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 3.10.1 + version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@18.3.1)(react@19.2.8)(redux@5.0.1) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -187,169 +202,298 @@ importers: version: 6.0.0 remark-gfm: specifier: ^4.0.1 - version: 4.0.1(supports-color@7.2.0) + version: 4.0.1 sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tailwind-merge: - specifier: ^3.3.1 - version: 3.3.1 + specifier: ^3.6.0 + version: 3.6.0 vaul: specifier: ^1.1.2 - version: 1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) zod: - specifier: ^4.1.12 - version: 4.1.12 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@biomejs/biome': - specifier: ^2.5.5 - version: 2.5.5 - '@eslint/eslintrc': - specifier: ^3 - version: 3.3.1(supports-color@7.2.0) + specifier: ^2.5.7 + version: 2.5.7 '@tailwindcss/postcss': - specifier: ^4 - version: 4.1.16 + specifier: ^4.3.3 + version: 4.3.3 '@tailwindcss/typography': - specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.1.16) + specifier: ^0.5.20 + version: 0.5.20(tailwindcss@4.3.3) + '@testing-library/jest-dom': + specifier: ^7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.3 + version: 14.6.3(@testing-library/dom@10.4.1) '@types/node': - specifier: ^20 - version: 20.19.24 + specifier: ^26.1.2 + version: 26.1.2 '@types/react': - specifier: ^19 - version: 19.2.2 + specifier: ^19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19 - version: 19.2.2(@types/react@19.2.2) + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + axe-core: + specifier: ^4.13.0 + version: 4.13.0 eslint: - specifier: ^9 - version: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) + specifier: ^9.39.5 + version: 9.39.5(jiti@2.7.0) eslint-config-next: - specifier: 15.5.6 - version: 15.5.6(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + specifier: 16.3.0 + version: 16.3.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + eslint-plugin-unused-imports: + specifier: ^4.4.1 + version: 4.4.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 tailwindcss: - specifier: ^4 - version: 4.1.16 + specifier: ^4.3.3 + version: 4.3.3 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(jsdom@30.0.1)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.1)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@asamuzakjp/css-color@6.0.5': + resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.5': - resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.5.7': + resolution: {integrity: sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.5': - resolution: {integrity: sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==} + '@biomejs/cli-darwin-arm64@2.5.7': + resolution: {integrity: sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.5': - resolution: {integrity: sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==} + '@biomejs/cli-darwin-x64@2.5.7': + resolution: {integrity: sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.5': - resolution: {integrity: sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==} + '@biomejs/cli-linux-arm64-musl@2.5.7': + resolution: {integrity: sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.5': - resolution: {integrity: sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==} + '@biomejs/cli-linux-arm64@2.5.7': + resolution: {integrity: sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.5': - resolution: {integrity: sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==} + '@biomejs/cli-linux-x64-musl@2.5.7': + resolution: {integrity: sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.5': - resolution: {integrity: sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==} + '@biomejs/cli-linux-x64@2.5.7': + resolution: {integrity: sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.5': - resolution: {integrity: sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==} + '@biomejs/cli-win32-arm64@2.5.7': + resolution: {integrity: sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.5': - resolution: {integrity: sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==} + '@biomejs/cli-win32-x64@2.5.7': + resolution: {integrity: sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} - '@codemirror/commands@6.10.3': - resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.12': + resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} '@codemirror/lang-sql@6.10.0': resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} - '@codemirror/language@6.12.3': - resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} - '@codemirror/lint@6.9.6': - resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} '@codemirror/search@6.7.0': resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} - '@codemirror/state@6.6.0': - resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} '@codemirror/theme-one-dark@6.1.3': resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} - '@codemirror/view@6.43.0': - resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + '@codemirror/view@6.43.8': + resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} - '@date-fns/tz@1.4.1': - resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} @@ -379,17 +523,35 @@ packages: peerDependencies: react: '>=16.8.0' - '@emnapi/core@1.7.0': - resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.7.0': - resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -398,8 +560,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -410,12 +572,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.1': - resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -426,29 +588,38 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true - '@floating-ui/dom@1.7.4': - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/react-dom@2.1.6': - resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@fontsource/noto-sans-sc@5.2.8': - resolution: {integrity: sha512-8T8HxIS3uAMCfaQawKRH/6yYZ1oRnJZB/CrGwfxGgJa+zAOBgx2lqZMiTY/WbQpLGlPRqX4zHXJYI09CI2q6tA==} + '@fontsource/noto-sans-sc@5.3.0': + resolution: {integrity: sha512-HeqIlGm0+ohOKxZLuHj1qW6r6avHH0OWdKERAcSDI0RQ+MXrteuLKA+M+5eOA8rYy0MFvOR5AT0fQo2rUkye0Q==} '@formatjs/fast-memoize@3.1.7': resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} - '@formatjs/icu-messageformat-parser@3.5.15': - resolution: {integrity: sha512-5o4grXKotAB3JqQuisLApHG43g17N+paoRTa92Jiz35Zvfemq0cVf4EDvuxyHAzmsJji7igaEowicLO/VmfJ8Q==} + '@formatjs/icu-messageformat-parser@3.5.16': + resolution: {integrity: sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==} '@formatjs/icu-skeleton-parser@2.1.11': resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} @@ -456,17 +627,94 @@ packages: '@formatjs/intl-localematcher@0.8.13': resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==} - '@hookform/resolvers@5.2.2': - resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + '@hookform/resolvers@5.7.1': + resolution: {integrity: sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==} peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@typeschema/main': '>=0.13.7' + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^1.2.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^3.10.3 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 + nope-validator: '>=0.12.0' react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=3.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@typeschema/main': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -477,156 +725,165 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -640,6 +897,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -649,72 +909,85 @@ packages: '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + '@lezer/css@1.3.6': + resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==} + '@lezer/highlight@1.2.3': resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + '@lezer/lr@1.4.10': resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@next/env@16.2.7': - resolution: {integrity: sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==} + '@next/env@16.3.0': + resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} - '@next/eslint-plugin-next@15.5.6': - resolution: {integrity: sha512-YxDvsT2fwy1j5gMqk3ppXlsgDopHnkM4BoxSVASbvvgh5zgsK8lvWerDzPip8k3WVzsTZ1O7A7si1KNfN4OZfQ==} + '@next/eslint-plugin-next@16.3.0': + resolution: {integrity: sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==} - '@next/swc-darwin-arm64@16.2.7': - resolution: {integrity: sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==} + '@next/swc-darwin-arm64@16.3.0': + resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.7': - resolution: {integrity: sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==} + '@next/swc-darwin-x64@16.3.0': + resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.7': - resolution: {integrity: sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==} + '@next/swc-linux-arm64-gnu@16.3.0': + resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.7': - resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==} + '@next/swc-linux-arm64-musl@16.3.0': + resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.7': - resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==} + '@next/swc-linux-x64-gnu@16.3.0': + resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.7': - resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==} + '@next/swc-linux-x64-musl@16.3.0': + resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.7': - resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==} + '@next/swc-win32-arm64-msvc@16.3.0': + resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.7': - resolution: {integrity: sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==} + '@next/swc-win32-x64-msvc@16.3.0': + resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -735,6 +1008,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@parcel/watcher-android-arm64@2.6.0': resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} @@ -817,14 +1093,14 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} - '@radix-ui/react-accessible-icon@1.1.7': - resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + '@radix-ui/react-accessible-icon@1.1.15': + resolution: {integrity: sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -836,8 +1112,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-accordion@1.2.12': - resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + '@radix-ui/react-accordion@1.2.20': + resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -849,8 +1125,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-alert-dialog@1.1.15': - resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + '@radix-ui/react-alert-dialog@1.1.23': + resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -862,8 +1138,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -875,8 +1151,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-aspect-ratio@1.1.7': - resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + '@radix-ui/react-aspect-ratio@1.1.15': + resolution: {integrity: sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -888,8 +1164,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-avatar@1.1.10': - resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + '@radix-ui/react-avatar@1.2.6': + resolution: {integrity: sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -901,8 +1177,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-avatar@1.1.11': - resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + '@radix-ui/react-checkbox@1.3.11': + resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -914,8 +1190,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-checkbox@1.3.3': - resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + '@radix-ui/react-collapsible@1.1.20': + resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -927,8 +1203,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -940,8 +1216,17 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.7': + resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -953,8 +1238,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -962,8 +1247,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context-menu@2.2.16': - resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -975,8 +1260,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -984,17 +1269,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.1.3': - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1006,17 +1282,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1028,8 +1295,17 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1041,17 +1317,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + '@radix-ui/react-form@0.1.16': + resolution: {integrity: sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1063,8 +1330,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-form@0.1.8': - resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + '@radix-ui/react-hover-card@1.1.23': + resolution: {integrity: sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1076,8 +1343,17 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-hover-card@1.1.15': - resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.15': + resolution: {integrity: sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1089,17 +1365,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-label@2.1.7': - resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1111,8 +1378,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-label@2.1.8': - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + '@radix-ui/react-menubar@1.1.24': + resolution: {integrity: sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1124,8 +1391,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + '@radix-ui/react-navigation-menu@1.2.22': + resolution: {integrity: sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1137,8 +1404,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menubar@1.1.16': - resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + '@radix-ui/react-one-time-password-field@0.1.16': + resolution: {integrity: sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1150,8 +1417,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-navigation-menu@1.2.14': - resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + '@radix-ui/react-password-toggle-field@0.1.11': + resolution: {integrity: sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1163,8 +1430,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-one-time-password-field@0.1.8': - resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1176,8 +1443,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-password-toggle-field@0.1.3': - resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1189,8 +1456,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1202,8 +1469,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1215,8 +1482,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1228,8 +1495,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + '@radix-ui/react-progress@1.1.16': + resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1241,8 +1508,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + '@radix-ui/react-radio-group@1.4.7': + resolution: {integrity: sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1254,8 +1521,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1267,8 +1534,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.7': - resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + '@radix-ui/react-scroll-area@1.2.18': + resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1280,8 +1547,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.8': - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + '@radix-ui/react-select@2.3.7': + resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1293,8 +1560,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-radio-group@1.3.8': - resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + '@radix-ui/react-separator@1.1.15': + resolution: {integrity: sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1306,8 +1573,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + '@radix-ui/react-slider@1.4.7': + resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1319,8 +1586,17 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.7': + resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1332,8 +1608,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-select@2.2.6': - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1345,8 +1621,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.7': - resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + '@radix-ui/react-toast@1.2.23': + resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1358,8 +1634,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.8': - resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + '@radix-ui/react-toggle-group@1.1.19': + resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1371,8 +1647,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slider@1.3.6': - resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + '@radix-ui/react-toggle@1.1.18': + resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1384,26 +1660,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + '@radix-ui/react-toolbar@1.1.19': + resolution: {integrity: sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1415,8 +1673,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1428,8 +1686,89 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toast@1.2.15': - resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.5': + resolution: {integrity: sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1441,260 +1780,210 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle-group@1.1.11': - resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 peerDependenciesMeta: - '@types/react': + react: optional: true - '@types/react-dom': + react-redux: optional: true - '@radix-ui/react-toggle@1.1.10': - resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] - '@radix-ui/react-toolbar@1.1.11': - resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] - '@radix-ui/react-use-is-hydrated@0.1.0': - resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] - '@radix-ui/react-visually-hidden@1.2.4': - resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@rushstack/eslint-patch@1.14.1': - resolution: {integrity: sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==} - '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@swc/core-darwin-arm64@1.15.46': - resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} + '@swc/core-darwin-arm64@1.16.0': + resolution: {integrity: sha512-SJQPl+xG/zB8bNjC/gTg3WOmOvz7EzlQD+VShfCKFYPNr2qvb+vATUY11vYEjnMWCn6wV8H8eAtjQrVflYyX5A==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.46': - resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==} + '@swc/core-darwin-x64@1.16.0': + resolution: {integrity: sha512-ql2JVch8V5t1i+HxiiuD4oVDI1dOku4/e3QiCkplONrm3SLitqNAP+nztHN51fSG2IgGuOwpAi3hgA+ukT5yQg==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.46': - resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==} + '@swc/core-linux-arm-gnueabihf@1.16.0': + resolution: {integrity: sha512-PcdDBaRbe39y37h1rXVkhNy7mEU7f8b34KD761C68R23EsfMsj5oDPVddRzGdSRAvwwSfH0WSNEHgYmc/AJipg==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.46': - resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==} + '@swc/core-linux-arm64-gnu@1.16.0': + resolution: {integrity: sha512-t21IUztHQ/COucy7Kk9eIlehmq08H/hYq7aRA6fZox3S5ddi6TxWPK6e5S/+aTCf6+Od9qQ+LIpjHMiTy737vA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.46': - resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==} + '@swc/core-linux-arm64-musl@1.16.0': + resolution: {integrity: sha512-d9+iajbMB87b0umgbP+Gy3yBDSDgty4Q6H5pZ8fgTb/dOoKIwwynP4L4kvWCOFg2i49kxmAAUs1uJZh9s0E+RQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-ppc64-gnu@1.15.46': - resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==} + '@swc/core-linux-ppc64-gnu@1.16.0': + resolution: {integrity: sha512-QRpeKGOg+B0qmo3BFU+6rL/gpoKYYJ7OFSMf5DNMafohYZ/iq2qvAH9Gcrf8NxROj3iooKOVewJ+YgahH1nSLw==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] libc: [glibc] - '@swc/core-linux-s390x-gnu@1.15.46': - resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==} + '@swc/core-linux-s390x-gnu@1.16.0': + resolution: {integrity: sha512-q+Vr/hmHCcRXT/WFzOJC+T6GGEEtq2iaTtmyLfxO7yzu4ckgcqSNkg9m181wfNhuMwfNBoBhOfwQCnLsGZ5F4g==} engines: {node: '>=10'} cpu: [s390x] os: [linux] libc: [glibc] - '@swc/core-linux-x64-gnu@1.15.46': - resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==} + '@swc/core-linux-x64-gnu@1.16.0': + resolution: {integrity: sha512-DWVBc3QnpsSgKoq8N4rmZeZa5r/XrHdLkITsExN/tvTdqPtAPDPt+Ysy33OfgBlyN8lNe4xwsXWe6DXlRkJeRQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.46': - resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==} + '@swc/core-linux-x64-musl@1.16.0': + resolution: {integrity: sha512-6XCgDSc1HPf/5dpjvABhKHICiBcsuZyW3hQMkn8sxel0TqprkJGp+H4iaBYIUTPixhrBub2hBPtfjcZLE6yL3w==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.46': - resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==} + '@swc/core-win32-arm64-msvc@1.16.0': + resolution: {integrity: sha512-T/+9VVCZJ3AKEth9IP3U9AJ2YscQq+7LUqRTvfR4a2q36+Ri22oOwUizpAKOqQ42vb2Y/kOa4TOcJOfHoDIT/w==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.46': - resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==} + '@swc/core-win32-ia32-msvc@1.16.0': + resolution: {integrity: sha512-Pr1lsR/PMs8ndL0UWMrW8nLZ7H7sspIxBRDdjL8f+YJ/FJNASgzfunbVVXAqj0csgIJYHPZy+OW9smjFmk1Rcg==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.46': - resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==} + '@swc/core-win32-x64-msvc@1.16.0': + resolution: {integrity: sha512-ktdeYLgOQdaonvsj5tJijqgpb0wk7gfF80wCFVA0kucI1hhSUIyfcGbjo5+9sdqv38OhMnTdLoA6xbqgOgPQjw==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.46': - resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==} + '@swc/core@1.16.0': + resolution: {integrity: sha512-zSdvEHxBg00WhUNtW/u58hhcdR33gjtMQvOBo8F7POWJDyjRCt/miKfhidT3hCc/118RUwNnlEAmxiihFMbK4Q==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -1708,80 +1997,80 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/types@0.1.27': - resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} - '@tabler/icons-react@3.35.0': - resolution: {integrity: sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==} + '@tabler/icons-react@3.46.0': + resolution: {integrity: sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==} peerDependencies: react: '>= 16' - '@tabler/icons@3.35.0': - resolution: {integrity: sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==} + '@tabler/icons@3.46.0': + resolution: {integrity: sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==} - '@tailwindcss/node@4.1.16': - resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} - '@tailwindcss/oxide-android-arm64@4.1.16': - resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.16': - resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.16': - resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.16': - resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': - resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': - resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.1.16': - resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.1.16': - resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.1.16': - resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.1.16': - resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1792,60 +2081,105 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': - resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.16': - resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.16': - resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} - '@tailwindcss/postcss@4.1.16': - resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==} + '@tailwindcss/postcss@4.3.3': + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tailwindcss/typography@0.5.19': - resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + '@tailwindcss/typography@0.5.20': + resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} peerDependencies: - tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' - '@tanstack/query-core@5.101.0': - resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} - '@tanstack/react-query@5.101.0': - resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-table@8.21.3': - resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - '@tanstack/react-virtual@3.13.16': - resolution: {integrity: sha512-y4xLKvLu6UZWiGdNcgk3yYlzCznYIV0m8dSyUzr3eAC0dHLos5V74qhUHxutYddFGgGU8sWLkp6H5c2RCrsrXw==} + '@tanstack/react-store@0.11.0': + resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} + '@tanstack/react-table@9.0.0': + resolution: {integrity: sha512-Q/Z49MQcdMwge67U+LTjSEQJCnQE9/tNWK5IpiYex9JFDzfjNkLIi7yzGA4dfW4UpuMBrtqbSnSzn7oPr60T+w==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' - '@tanstack/virtual-core@3.13.16': - resolution: {integrity: sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w==} + '@tanstack/react-virtual@3.14.9': + resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tanstack/store@0.11.0': + resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + + '@tanstack/table-core@9.0.0': + resolution: {integrity: sha512-IyKCc4D7d/+I9euQntlVDQ7lnilmFRKpIROBeI5/866adFy+65xWvCFPz76NZ5j2lXe/RFDvFVV7bfETmwHEMA==} + engines: {node: '>=20'} + + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.0': + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.3': + resolution: {integrity: sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1853,6 +2187,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -1865,8 +2202,11 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - '@types/d3-shape@3.1.7': - resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} '@types/d3-time@3.0.4': resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} @@ -1874,17 +2214,26 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1898,25 +2247,25 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.24': - resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} - '@types/prismjs@1.26.5': - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} - '@types/react-dom@19.2.2': - resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.2': - resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1927,67 +2276,70 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/eslint-plugin@8.46.3': - resolution: {integrity: sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.46.3 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': ^8.66.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.46.3': - resolution: {integrity: sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==} + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.46.3': - resolution: {integrity: sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==} + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.46.3': - resolution: {integrity: sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==} + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.3': - resolution: {integrity: sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==} + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.46.3': - resolution: {integrity: sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==} + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.46.3': - resolution: {integrity: sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==} + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.46.3': - resolution: {integrity: sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==} + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.46.3': - resolution: {integrity: sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==} + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.46.3': - resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==} + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@uiw/codemirror-extensions-basic-setup@4.25.10': - resolution: {integrity: sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==} + '@uiw/codemirror-extensions-basic-setup@4.25.11': + resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' '@codemirror/commands': '>=6.0.0' @@ -1997,8 +2349,8 @@ packages: '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' - '@uiw/react-codemirror@4.25.10': - resolution: {integrity: sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==} + '@uiw/react-codemirror@4.25.11': + resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==} peerDependencies: '@babel/runtime': '>=7.11.0' '@codemirror/state': '>=6.0.0' @@ -2008,130 +2360,203 @@ packages: react: '>=17.0.0' react-dom: '>=17.0.0' - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} cpu: [arm] os: [android] - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} cpu: [arm64] os: [android] - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} cpu: [arm64] os: [darwin] - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} cpu: [x64] os: [darwin] - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} cpu: [x64] os: [freebsd] - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} cpu: [arm] os: [linux] - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] libc: [musl] - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] libc: [musl] - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] libc: [musl] - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} cpu: [arm64] os: [win32] - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} cpu: [ia32] os: [win32] - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} cpu: [x64] os: [win32] + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@xyflow/react@12.11.2': + resolution: {integrity: sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.79': + resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2139,6 +2564,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2175,6 +2603,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -2189,12 +2621,12 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.0: - resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} - axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2209,31 +2641,47 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} - baseline-browser-mapping@2.10.34: - resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==} + baseline-browser-mapping@2.11.12: + resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} engines: {node: '>=6.0.0'} hasBin: true - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} engines: {node: '>= 0.4'} call-bound@1.0.4: @@ -2244,8 +2692,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001753: - resolution: {integrity: sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} canvg@3.0.11: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} @@ -2254,6 +2702,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2273,6 +2725,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2303,11 +2758,20 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - core-js@3.47.0: - resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} + + country-region-data@4.1.0: + resolution: {integrity: sha512-20fGwnpj5Q3HlQTHdMUstpD4pixphymWBj1f9/EonKEvnEDCaq1S3peE1O7NLxHI1zSa6W+5do/u7xS7UVfZtw==} crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -2319,13 +2783,20 @@ packages: css-line-break@2.1.0: resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} @@ -2335,12 +2806,20 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} - d3-format@3.1.0: - resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} engines: {node: '>=12'} d3-interpolate@3.0.1: @@ -2355,6 +2834,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -2371,9 +2854,23 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2386,11 +2883,8 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - date-fns-jalali@4.1.0-0: - resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} - - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} @@ -2412,8 +2906,11 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - decode-named-character-reference@1.2.0: - resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2448,16 +2945,31 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dompurify@3.4.12: - resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + echarts-for-react@3.0.6: + resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==} + peerDependencies: + echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + react: ^15.0.0 || >=16.0.0 + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + + electron-to-chromium@1.5.401: + resolution: {integrity: sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==} + embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} peerDependencies: @@ -2474,12 +2986,20 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -2490,12 +3010,15 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.1: - resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -2506,10 +3029,17 @@ packages: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2518,17 +3048,17 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@15.5.6: - resolution: {integrity: sha512-cGr3VQlPsZBEv8rtYp4BpG1KNXDqGvPo9VC1iaCgIA11OfziC/vczng+TnAS3WpRIR3Q5ye/6yl+CRUuZ1fPGg==} + eslint-config-next@16.3.0: + resolution: {integrity: sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==} peerDependencies: - eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 + eslint: '>=9.0.0' typescript: '>=3.3.1' peerDependenciesMeta: typescript: optional: true - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} eslint-import-resolver-typescript@3.10.1: resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} @@ -2543,8 +3073,8 @@ packages: eslint-plugin-import-x: optional: true - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -2580,11 +3110,11 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} @@ -2592,6 +3122,15 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2604,8 +3143,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.39.1: - resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2618,8 +3161,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -2633,12 +3176,19 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2646,18 +3196,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@5.3.2: - resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==} - engines: {node: '>=6.0.0'} - fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2667,8 +3209,8 @@ packages: fast-png@6.4.0: resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fault@1.0.4: resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} @@ -2682,8 +3224,8 @@ packages: picomatch: optional: true - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -2701,11 +3243,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2717,16 +3259,16 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - framer-motion@12.23.24: - resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2739,11 +3281,16 @@ packages: react-dom: optional: true + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} engines: {node: '>= 0.4'} functions-have-names@1.2.3: @@ -2753,6 +3300,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2769,8 +3320,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -2787,6 +3338,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -2798,9 +3353,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2824,8 +3376,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} hast-util-heading-rank@3.0.0: @@ -2852,6 +3404,12 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -2862,6 +3420,10 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -2869,8 +3431,12 @@ packages: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} - icu-minify@4.13.3: - resolution: {integrity: sha512-dCbcvYH4YBgR+SgCtnXGIquuc6JkS7e4n+5mmgseQ5z81KJIWbs5ucBjnrE72HFct6Ddr2yWjX6o1j8DT7MI5w==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + icu-minify@4.13.6: + resolution: {integrity: sha512-iYZGCJZ+kX6o7GrxpVe2sOSdW86AvEqh8RQBvWeBd9jqmuABsMc2B6xongACfItLOogyIWH6GuBslNHr79OU8Q==} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} @@ -2880,6 +3446,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@11.1.15: + resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2888,6 +3457,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2905,8 +3478,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - intl-messageformat@11.2.12: - resolution: {integrity: sha512-KW70Xxfcvy7vV3qODfvShWkFDPMqKDAa4N+hSyVBWGNtVhTUFYaqlD/l88DaYPKiVcPP4rPQ3qnH7i5K82Mg7g==} + intl-messageformat@11.2.13: + resolution: {integrity: sha512-JaPaE6TIX+TAS5XLhDUh41geLw4QfBHX4s5pW8Km+L9fVC8HzB9yOuhbh4EMR/F1+8C6b9qk4763Cv+LdOG1kg==} iobuffer@5.4.0: resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} @@ -2940,8 +3513,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -2955,6 +3528,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2994,6 +3571,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3040,15 +3620,29 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} hasBin: true json-buffer@3.0.1: @@ -3064,6 +3658,11 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jspdf@4.2.1: resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} @@ -3085,78 +3684,78 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} locate-path@6.0.0: @@ -3166,9 +3765,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3182,11 +3778,22 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} - lucide-react@0.552.0: - resolution: {integrity: sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.28.0: + resolution: {integrity: sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3200,8 +3807,8 @@ packages: mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} @@ -3242,6 +3849,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3342,24 +3952,28 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - motion-dom@12.23.23: - resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} - motion-utils@12.23.6: - resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} - motion@12.23.24: - resolution: {integrity: sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==} + motion@12.43.0: + resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3375,8 +3989,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3392,11 +4006,11 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next-intl-swc-plugin-extractor@4.13.3: - resolution: {integrity: sha512-Oy86w/VRwqOu6sHEXwthEGhgnQszS79e47i3Kkzv4r+Yfz9ilZMSen8Sp8vtAwuc1nAZPrN13IbsLC5jmc9fVQ==} + next-intl-swc-plugin-extractor@4.13.6: + resolution: {integrity: sha512-M2L8jtPEAXj0CPmXbiW66THdr3OnDqA9IsU1hqv3CdxtVow3Bl9eXPdT9Opeji7L4AFrUZ016dJOs+CoTw66OA==} - next-intl@4.13.3: - resolution: {integrity: sha512-+bAjmPLsRFG/YAN1ged7wVCbuYvYjEW06ApBRdM7VbtNfXYN1iBFNh28Bi1sX5xGSFvAsGCBC1K2Zjtvn1AMvQ==} + next-intl@4.13.6: + resolution: {integrity: sha512-loS6tjWWkr/IP+EV1yXUm9URB54QmZOp4+ZsMZNmeYxY8IZxLvO2esUegnXIDxj5DpK/4BsxwDGfGhlqodpkCQ==} peerDependencies: next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 @@ -3411,8 +4025,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.7: - resolution: {integrity: sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==} + next@16.3.0: + resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3435,6 +4049,14 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + + node-releases@2.0.52: + resolution: {integrity: sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==} + engines: {node: '>=18'} + normalize-wheel@1.0.1: resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} @@ -3470,12 +4092,16 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} p-limit@3.1.0: @@ -3486,8 +4112,8 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -3496,6 +4122,9 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3507,20 +4136,19 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -3528,8 +4156,8 @@ packages: pinyin-match@1.2.10: resolution: {integrity: sha512-Xo1uMd9n56/xP2EAn7yfApnTmAGCLGqxFIm1fuOnSMty0xhqRF3+ZwVjp2yWFD/alv60RUWa/cZoQSeTSp1HxA==} - po-parser@2.1.1: - resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + po-parser@2.2.0: + resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} @@ -3539,18 +4167,22 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3558,8 +4190,8 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} @@ -3572,8 +4204,8 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - radix-ui@1.4.3: - resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + radix-ui@1.6.7: + resolution: {integrity: sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -3588,37 +4220,44 @@ packages: raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} - react-day-picker@9.11.1: - resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} engines: {node: '>=18'} peerDependencies: + '@types/react': '>=16.8.0' react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: - react: ^19.2.3 + react: ^19.2.8 - react-easy-crop@5.5.6: - resolution: {integrity: sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==} + react-easy-crop@6.2.3: + resolution: {integrity: sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==} peerDependencies: react: '>=16.4.0' react-dom: '>=16.4.0' - react-hook-form@7.68.0: - resolution: {integrity: sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==} + react-hook-form@7.84.0: + resolution: {integrity: sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-image-crop@11.0.10: - resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + react-image-crop@11.1.2: + resolution: {integrity: sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==} peerDependencies: react: '>=16.13.1' react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -3628,6 +4267,18 @@ packages: '@types/react': '>=18' react: '>=18' + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3638,8 +4289,8 @@ packages: '@types/react': optional: true - react-remove-scroll@2.7.1: - resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} engines: {node: '>=10'} peerDependencies: '@types/react': '*' @@ -3648,12 +4299,6 @@ packages: '@types/react': optional: true - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -3664,32 +4309,35 @@ packages: '@types/react': optional: true - react-syntax-highlighter@16.1.0: - resolution: {integrity: sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==} + react-syntax-highlighter@16.1.1: + resolution: {integrity: sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==} engines: {node: '>= 16.20.2'} peerDependencies: react: '>= 0.14.0' - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - - recharts@2.15.4: - resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} - engines: {node: '>=14'} - deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} @@ -3723,6 +4371,13 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3730,15 +4385,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} - hasBin: true - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3747,11 +4398,16 @@ packages: resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} engines: {node: '>= 0.8.15'} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} safe-push-apply@1.0.0: @@ -3762,6 +4418,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3769,8 +4429,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -3786,9 +4446,14 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3798,8 +4463,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -3810,10 +4475,16 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + size-sensor@1.0.3: + resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -3824,16 +4495,29 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackblur-canvas@2.7.0: resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} engines: {node: '>=0.1.14'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3849,12 +4533,12 @@ packages: string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} engines: {node: '>= 0.4'} string.prototype.trimstart@1.0.8: @@ -3868,6 +4552,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3906,38 +4594,72 @@ packages: resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} engines: {node: '>=12.0.0'} - tailwind-merge@3.3.1: - resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tailwindcss@4.1.16: - resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + terser@5.49.1: + resolution: {integrity: sha512-7A2xlQ5EnGT8KPA92dUh6RbRYTVw8hEaEN9L1K68l4UOXFuV511NnAqObGoRqGOQofQcMypisu1s3xawCEHrvA==} + engines: {node: '>=10'} + hasBin: true + text-segmentation@1.0.3: resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -3945,6 +4667,9 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3967,12 +4692,19 @@ packages: resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} engines: {node: '>= 0.4'} - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -3980,8 +4712,12 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4001,11 +4737,17 @@ packages: unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - unist-util-visit@5.0.0: - resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4020,8 +4762,8 @@ packages: '@types/react': optional: true - use-intl@4.13.3: - resolution: {integrity: sha512-e1qMU4PokNEU7K1TtKROSl3Sv7JLsKh7GyNjuhKZVB444Qfv0qHjet59IZF6GtYE+VwGbhgWqDWfGTnuDPe2Bg==} + use-intl@4.13.6: + resolution: {integrity: sha512-RLej84qL6PGTDp/PSG3tqRpwr7IvJfOu4Qfv/uyy8CrnYn1oEOQb6osNJb++jZ7FQxqN3aQ5BI7wTIerUgrgMA==} peerDependencies: react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 @@ -4058,12 +4800,116 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4076,8 +4922,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} which@2.0.2: @@ -4085,196 +4931,425 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} - '@babel/helper-string-parser@7.27.1': {} + '@asamuzakjp/css-color@6.0.5': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 - '@babel/helper-validator-identifier@7.28.5': {} + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 - '@babel/runtime@7.28.4': {} + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 '@babel/runtime@7.29.7': {} - '@babel/types@7.28.5': + '@babel/template@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 - '@biomejs/biome@2.5.5': + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@biomejs/biome@2.5.7': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.5 - '@biomejs/cli-darwin-x64': 2.5.5 - '@biomejs/cli-linux-arm64': 2.5.5 - '@biomejs/cli-linux-arm64-musl': 2.5.5 - '@biomejs/cli-linux-x64': 2.5.5 - '@biomejs/cli-linux-x64-musl': 2.5.5 - '@biomejs/cli-win32-arm64': 2.5.5 - '@biomejs/cli-win32-x64': 2.5.5 + '@biomejs/cli-darwin-arm64': 2.5.7 + '@biomejs/cli-darwin-x64': 2.5.7 + '@biomejs/cli-linux-arm64': 2.5.7 + '@biomejs/cli-linux-arm64-musl': 2.5.7 + '@biomejs/cli-linux-x64': 2.5.7 + '@biomejs/cli-linux-x64-musl': 2.5.7 + '@biomejs/cli-win32-arm64': 2.5.7 + '@biomejs/cli-win32-x64': 2.5.7 - '@biomejs/cli-darwin-arm64@2.5.5': + '@biomejs/cli-darwin-arm64@2.5.7': optional: true - '@biomejs/cli-darwin-x64@2.5.5': + '@biomejs/cli-darwin-x64@2.5.7': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.5': + '@biomejs/cli-linux-arm64-musl@2.5.7': optional: true - '@biomejs/cli-linux-arm64@2.5.5': + '@biomejs/cli-linux-arm64@2.5.7': optional: true - '@biomejs/cli-linux-x64-musl@2.5.5': + '@biomejs/cli-linux-x64-musl@2.5.7': optional: true - '@biomejs/cli-linux-x64@2.5.5': + '@biomejs/cli-linux-x64@2.5.7': optional: true - '@biomejs/cli-win32-arm64@2.5.5': + '@biomejs/cli-win32-arm64@2.5.7': optional: true - '@biomejs/cli-win32-x64@2.5.5': + '@biomejs/cli-win32-x64@2.5.7': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@codemirror/autocomplete@6.20.3': dependencies: - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 - '@codemirror/commands@6.10.3': + '@codemirror/commands@6.10.4': dependencies: - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + + '@codemirror/lang-html@6.4.12': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + '@codemirror/lang-sql@6.10.0': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 - '@codemirror/language@6.12.3': + '@codemirror/language@6.12.4': dependencies: - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 style-mod: 4.1.3 - '@codemirror/lint@6.9.6': + '@codemirror/lint@6.9.7': dependencies: - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 crelt: 1.0.6 '@codemirror/search@6.7.0': dependencies: - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 crelt: 1.0.6 - '@codemirror/state@6.6.0': + '@codemirror/state@6.7.1': dependencies: '@marijn/find-cluster-break': 1.0.2 '@codemirror/theme-one-dark@6.1.3': dependencies: - '@codemirror/language': 6.12.3 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 '@lezer/highlight': 1.2.3 - '@codemirror/view@6.43.0': + '@codemirror/view@6.43.8': dependencies: - '@codemirror/state': 6.6.0 + '@codemirror/state': 6.7.1 crelt: 1.0.6 style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@date-fns/tz@1.4.1': {} + '@csstools/color-helpers@6.1.0': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.3)': + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - react: 19.2.3 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@date-fns/tz@1.5.0': {} + + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.3) - '@dnd-kit/utilities': 3.2.2(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 - '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@dnd-kit/utilities': 3.2.2(react@19.2.3) - react: 19.2.3 + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@dnd-kit/utilities': 3.2.2(react@19.2.3) - react: 19.2.3 + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.2.3)': + '@dnd-kit/utilities@3.2.2(react@19.2.8)': dependencies: - react: 19.2.3 + react: 19.2.8 tslib: 2.8.1 - '@emnapi/core@1.7.0': + '@emnapi/core@1.10.0': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.0': + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))': + '@emnapi/runtime@1.11.3': dependencies: - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1(supports-color@7.2.0)': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@7.2.0) - minimatch: 3.1.2 + debug: 4.4.3 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4286,21 +5361,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1(supports-color@7.2.0)': + '@eslint/eslintrc@3.3.6': dependencies: - ajv: 6.12.6 - debug: 4.4.3(supports-color@7.2.0) + ajv: 6.15.0 + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 + js-yaml: 4.3.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.1': {} + '@eslint/js@9.39.5': {} '@eslint/object-schema@2.1.7': {} @@ -4309,28 +5384,30 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.3': + '@exodus/bytes@1.15.1': {} + + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.4': + '@floating-ui/dom@1.8.0': dependencies: - '@floating-ui/core': 1.7.3 - '@floating-ui/utils': 0.2.10 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@floating-ui/dom': 1.7.4 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) - '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.12': {} - '@fontsource/noto-sans-sc@5.2.8': {} + '@fontsource/noto-sans-sc@5.3.0': {} '@formatjs/fast-memoize@3.1.7': {} - '@formatjs/icu-messageformat-parser@3.5.15': + '@formatjs/icu-messageformat-parser@3.5.16': dependencies: '@formatjs/icu-skeleton-parser': 2.1.11 @@ -4340,117 +5417,135 @@ snapshots: dependencies: '@formatjs/fast-memoize': 3.1.7 - '@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.2.3))': + '@hookform/resolvers@5.7.1(@standard-schema/spec@1.1.0)(react-hook-form@7.84.0(react@19.2.8))(zod@4.4.3)': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.68.0(react@19.2.3) + react-hook-form: 7.84.0(react@19.2.8) + optionalDependencies: + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': + '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.2 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.2 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': + '@img/sharp-freebsd-wasm32@0.35.3': dependencies: - '@emnapi/runtime': 1.7.0 + '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-libvips-darwin-arm64@1.3.2': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-libvips-darwin-x64@1.3.2': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -4465,6 +5560,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -4474,51 +5575,79 @@ snapshots: '@lezer/common@1.5.2': {} + '@lezer/css@1.3.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/highlight@1.2.3': dependencies: '@lezer/common': 1.5.2 + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/lr@1.4.10': dependencies: '@lezer/common': 1.5.2 '@marijn/find-cluster-break@1.0.2': {} - '@napi-rs/wasm-runtime@0.2.12': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.7.0 - '@emnapi/runtime': 1.7.0 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.2.7': {} - - '@next/eslint-plugin-next@15.5.6': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@next/env@16.3.0': {} + + '@next/eslint-plugin-next@16.3.0(eslint@9.39.5(jiti@2.7.0))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 + transitivePeerDependencies: + - eslint - '@next/swc-darwin-arm64@16.2.7': + '@next/swc-darwin-arm64@16.3.0': optional: true - '@next/swc-darwin-x64@16.2.7': + '@next/swc-darwin-x64@16.3.0': optional: true - '@next/swc-linux-arm64-gnu@16.2.7': + '@next/swc-linux-arm64-gnu@16.3.0': optional: true - '@next/swc-linux-arm64-musl@16.2.7': + '@next/swc-linux-arm64-musl@16.3.0': optional: true - '@next/swc-linux-x64-gnu@16.2.7': + '@next/swc-linux-x64-gnu@16.3.0': optional: true - '@next/swc-linux-x64-musl@16.2.7': + '@next/swc-linux-x64-musl@16.3.0': optional: true - '@next/swc-win32-arm64-msvc@16.2.7': + '@next/swc-win32-arm64-msvc@16.3.0': optional: true - '@next/swc-win32-x64-msvc@16.2.7': + '@next/swc-win32-x64-msvc@16.3.0': optional: true '@nodelib/fs.scandir@2.1.5': @@ -4531,10 +5660,12 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.139.0': {} + '@parcel/watcher-android-arm64@2.6.0': optional: true @@ -4591,886 +5722,877 @@ snapshots: '@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0 - '@radix-ui/number@1.1.1': {} + '@radix-ui/number@1.1.3': {} - '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-accessible-icon@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-aspect-ratio@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-avatar@1.2.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - react: 19.2.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 - '@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.2 - - '@radix-ui/react-context@1.1.3(@types/react@19.2.2)(react@19.2.3)': - dependencies: - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.2 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.3 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.3 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-form@0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-label@2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) - - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-menubar@1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-one-time-password-field@0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-password-toggle-field@0.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/rect': 1.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-radio-group@1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) - - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 - '@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-switch@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-slot@1.2.4(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-toolbar@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.3 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-use-escape-keydown@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.3 - use-sync-external-store: 1.6.0(react@19.2.3) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.3 + '@radix-ui/rect': 1.1.3 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.3 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/rect': 1.1.1 - react: 19.2.3 + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.2)(react@19.2.3)': + '@radix-ui/rect@1.1.3': {} + + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - react: 19.2.3 + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.15 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 optionalDependencies: - '@types/react': 19.2.2 + react: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true - '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true - '@radix-ui/rect@1.1.1': {} + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} '@rtsao/scc@1.1.0': {} - '@rushstack/eslint-patch@1.14.1': {} - '@schummar/icu-type-parser@1.21.5': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} - '@swc/core-darwin-arm64@1.15.46': + '@swc/core-darwin-arm64@1.16.0': optional: true - '@swc/core-darwin-x64@1.15.46': + '@swc/core-darwin-x64@1.16.0': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.46': + '@swc/core-linux-arm-gnueabihf@1.16.0': optional: true - '@swc/core-linux-arm64-gnu@1.15.46': + '@swc/core-linux-arm64-gnu@1.16.0': optional: true - '@swc/core-linux-arm64-musl@1.15.46': + '@swc/core-linux-arm64-musl@1.16.0': optional: true - '@swc/core-linux-ppc64-gnu@1.15.46': + '@swc/core-linux-ppc64-gnu@1.16.0': optional: true - '@swc/core-linux-s390x-gnu@1.15.46': + '@swc/core-linux-s390x-gnu@1.16.0': optional: true - '@swc/core-linux-x64-gnu@1.15.46': + '@swc/core-linux-x64-gnu@1.16.0': optional: true - '@swc/core-linux-x64-musl@1.15.46': + '@swc/core-linux-x64-musl@1.16.0': optional: true - '@swc/core-win32-arm64-msvc@1.15.46': + '@swc/core-win32-arm64-msvc@1.16.0': optional: true - '@swc/core-win32-ia32-msvc@1.15.46': + '@swc/core-win32-ia32-msvc@1.16.0': optional: true - '@swc/core-win32-x64-msvc@1.15.46': + '@swc/core-win32-x64-msvc@1.16.0': optional: true - '@swc/core@1.15.46': + '@swc/core@1.16.0': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.27 + '@swc/types': 0.1.28 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.46 - '@swc/core-darwin-x64': 1.15.46 - '@swc/core-linux-arm-gnueabihf': 1.15.46 - '@swc/core-linux-arm64-gnu': 1.15.46 - '@swc/core-linux-arm64-musl': 1.15.46 - '@swc/core-linux-ppc64-gnu': 1.15.46 - '@swc/core-linux-s390x-gnu': 1.15.46 - '@swc/core-linux-x64-gnu': 1.15.46 - '@swc/core-linux-x64-musl': 1.15.46 - '@swc/core-win32-arm64-msvc': 1.15.46 - '@swc/core-win32-ia32-msvc': 1.15.46 - '@swc/core-win32-x64-msvc': 1.15.46 + '@swc/core-darwin-arm64': 1.16.0 + '@swc/core-darwin-x64': 1.16.0 + '@swc/core-linux-arm-gnueabihf': 1.16.0 + '@swc/core-linux-arm64-gnu': 1.16.0 + '@swc/core-linux-arm64-musl': 1.16.0 + '@swc/core-linux-ppc64-gnu': 1.16.0 + '@swc/core-linux-s390x-gnu': 1.16.0 + '@swc/core-linux-x64-gnu': 1.16.0 + '@swc/core-linux-x64-musl': 1.16.0 + '@swc/core-win32-arm64-msvc': 1.16.0 + '@swc/core-win32-ia32-msvc': 1.16.0 + '@swc/core-win32-x64-msvc': 1.16.0 '@swc/counter@0.1.3': {} @@ -5478,123 +6600,182 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/types@0.1.27': + '@swc/types@0.1.28': dependencies: '@swc/counter': 0.1.3 - '@tabler/icons-react@3.35.0(react@19.2.3)': + '@tabler/icons-react@3.46.0(react@19.2.8)': dependencies: - '@tabler/icons': 3.35.0 - react: 19.2.3 + '@tabler/icons': 3.46.0 + react: 19.2.8 - '@tabler/icons@3.35.0': {} + '@tabler/icons@3.46.0': {} - '@tailwindcss/node@4.1.16': + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.3 - jiti: 2.6.1 - lightningcss: 1.30.2 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.16 + tailwindcss: 4.3.3 - '@tailwindcss/oxide-android-arm64@4.1.16': + '@tailwindcss/oxide-android-arm64@4.3.3': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.16': + '@tailwindcss/oxide-darwin-arm64@4.3.3': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.16': + '@tailwindcss/oxide-darwin-x64@4.3.3': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.16': + '@tailwindcss/oxide-freebsd-x64@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.16': + '@tailwindcss/oxide-linux-x64-musl@4.3.3': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.16': + '@tailwindcss/oxide-wasm32-wasi@4.3.3': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': optional: true - '@tailwindcss/oxide@4.1.16': + '@tailwindcss/oxide@4.3.3': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.16 - '@tailwindcss/oxide-darwin-arm64': 4.1.16 - '@tailwindcss/oxide-darwin-x64': 4.1.16 - '@tailwindcss/oxide-freebsd-x64': 4.1.16 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.16 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.16 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.16 - '@tailwindcss/oxide-linux-x64-musl': 4.1.16 - '@tailwindcss/oxide-wasm32-wasi': 4.1.16 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/postcss@4.1.16': + '@tailwindcss/postcss@4.3.3': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.16 - '@tailwindcss/oxide': 4.1.16 - postcss: 8.5.6 - tailwindcss: 4.1.16 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.25 + tailwindcss: 4.3.3 - '@tailwindcss/typography@0.5.19(tailwindcss@4.1.16)': + '@tailwindcss/typography@0.5.20(tailwindcss@4.3.3)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.1.16 + tailwindcss: 4.3.3 - '@tanstack/query-core@5.101.0': {} + '@tanstack/query-core@5.101.4': {} - '@tanstack/react-query@5.101.0(react@19.2.3)': + '@tanstack/react-query@5.101.4(react@19.2.8)': dependencies: - '@tanstack/query-core': 5.101.0 - react: 19.2.3 + '@tanstack/query-core': 5.101.4 + react: 19.2.8 - '@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-store@0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tanstack/table-core': 8.21.3 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@tanstack/store': 0.11.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) - '@tanstack/react-virtual@3.13.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-table@9.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tanstack/virtual-core': 3.13.16 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@tanstack/react-store': 0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/table-core': 9.0.0 + react: 19.2.8 + transitivePeerDependencies: + - react-dom - '@tanstack/table-core@8.21.3': {} + '@tanstack/react-virtual@3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/virtual-core': 3.17.7 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) - '@tanstack/virtual-core@3.13.16': {} + '@tanstack/store@0.11.0': {} - '@tybys/wasm-util@0.10.1': + '@tanstack/table-core@9.0.0': + dependencies: + '@tanstack/store': 0.11.0 + + '@tanstack/virtual-core@3.17.7': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-interpolate@3.0.4': @@ -5607,7 +6788,9 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 - '@types/d3-shape@3.1.7': + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -5615,17 +6798,28 @@ snapshots: '@types/d3-timer@3.0.2': {} - '@types/debug@4.1.12': + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} - '@types/hast@3.0.4': + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -5639,24 +6833,24 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@20.19.24': + '@types/node@26.1.2': dependencies: - undici-types: 6.21.0 + undici-types: 8.3.0 '@types/pako@2.0.4': {} - '@types/prismjs@1.26.5': {} + '@types/prismjs@1.26.6': {} '@types/raf@3.4.3': optional: true - '@types/react-dom@19.2.2(@types/react@19.2.2)': + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - '@types/react@19.2.2': + '@types/react@19.2.18': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/trusted-types@2.0.7': optional: true @@ -5665,210 +6859,301 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@types/use-sync-external-store@0.0.6': {} + + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.3 - '@typescript-eslint/type-utils': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.3 - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - graphemer: 1.4.0 + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.66.0 + eslint: 9.39.5(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.3 - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/typescript-estree': 8.46.3(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.3 - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - typescript: 5.9.3 + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.3(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.66.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3) - '@typescript-eslint/types': 8.46.3 - debug: 4.4.3(supports-color@7.2.0) - typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/types': 8.66.0 + debug: 4.4.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.3': + '@typescript-eslint/scope-manager@8.66.0': dependencies: - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/visitor-keys': 8.46.3 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 - '@typescript-eslint/tsconfig-utils@8.46.3(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/typescript-estree': 8.46.3(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.46.3': {} + '@typescript-eslint/types@8.66.0': {} - '@typescript-eslint/typescript-estree@8.46.3(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.46.3(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3) - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/visitor-keys': 8.46.3 - debug: 4.4.3(supports-color@7.2.0) - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.3 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + '@typescript-eslint/project-service': 8.66.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)) - '@typescript-eslint/scope-manager': 8.46.3 - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/typescript-estree': 8.46.3(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - typescript: 5.9.3 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.3': + '@typescript-eslint/visitor-keys@8.66.0': dependencies: - '@typescript-eslint/types': 8.46.3 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.66.0 + eslint-visitor-keys: 5.0.1 - '@uiw/codemirror-extensions-basic-setup@4.25.10(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)': + '@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.0)(@codemirror/state@6.7.1)(@codemirror/view@6.43.8)': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/commands': 6.10.3 - '@codemirror/language': 6.12.3 - '@codemirror/lint': 6.9.6 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 '@codemirror/search': 6.7.0 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 - '@uiw/react-codemirror@4.25.10(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@uiw/react-codemirror@4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.0)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.8)(codemirror@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@babel/runtime': 7.28.4 - '@codemirror/commands': 6.10.3 - '@codemirror/state': 6.6.0 + '@babel/runtime': 7.29.7 + '@codemirror/commands': 6.10.4 + '@codemirror/state': 6.7.1 '@codemirror/theme-one-dark': 6.1.3 - '@codemirror/view': 6.43.0 - '@uiw/codemirror-extensions-basic-setup': 4.25.10(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0) + '@codemirror/view': 6.43.8 + '@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.0)(@codemirror/state@6.7.1)(@codemirror/view@6.43.8) codemirror: 6.0.2 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@codemirror/autocomplete' - '@codemirror/language' - '@codemirror/lint' - '@codemirror/search' - '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.3': {} - '@unrs/resolver-binding-android-arm-eabi@1.11.1': + '@unrs/resolver-binding-android-arm-eabi@1.12.2': optional: true - '@unrs/resolver-binding-android-arm64@1.11.1': + '@unrs/resolver-binding-android-arm64@1.12.2': optional: true - '@unrs/resolver-binding-darwin-arm64@1.11.1': + '@unrs/resolver-binding-darwin-arm64@1.12.2': optional: true - '@unrs/resolver-binding-darwin-x64@1.11.1': + '@unrs/resolver-binding-darwin-x64@1.12.2': optional: true - '@unrs/resolver-binding-freebsd-x64@1.11.1': + '@unrs/resolver-binding-freebsd-x64@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': optional: true - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': optional: true - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': optional: true - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': optional: true - '@unrs/resolver-binding-linux-x64-musl@1.11.1': + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': optional: true - '@unrs/resolver-binding-wasm32-wasi@1.11.1': + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': dependencies: - '@napi-rs/wasm-runtime': 0.2.12 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': optional: true - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': optional: true - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - acorn-jsx@5.3.2(acorn@8.15.0): + '@vitest/expect@4.1.10': dependencies: - acorn: 8.15.0 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 - acorn@8.15.0: {} + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.4(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.1) - ajv@6.12.6: + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@xyflow/react@12.11.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.15)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@xyflow/system': 0.0.79 + classcat: 5.0.5 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.79': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -5878,66 +7163,68 @@ snapshots: array-includes@3.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 is-string: 1.1.1 math-intrinsics: 1.1.0 array.prototype.findlast@1.2.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.findlastindex@1.2.6: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.flat@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -5948,50 +7235,69 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.0: {} + axe-core@4.13.0: {} - axios@1.15.0(debug@4.4.3(supports-color@7.2.0)): + axios@1.19.0: dependencies: - follow-redirects: 1.15.11(debug@4.4.3(supports-color@7.2.0)) - form-data: 4.0.5 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color axobject-query@4.1.0: {} babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.8 bail@2.0.2: {} balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-arraybuffer@1.0.2: optional: true - baseline-browser-mapping@2.10.34: {} + baseline-browser-mapping@2.11.12: {} - brace-expansion@1.1.12: + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@5.0.9: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: fill-range: 7.1.1 + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.12 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.401 + node-releases: 2.0.52 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + buffer-from@1.1.2: + optional: true + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: + call-bind@1.0.9: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 @@ -6005,13 +7311,13 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001753: {} + caniuse-lite@1.0.30001806: {} canvg@3.0.11: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 '@types/raf': 3.4.3 - core-js: 3.47.0 + core-js: 3.50.0 raf: 3.4.1 regenerator-runtime: 0.13.11 rgbcolor: 1.0.1 @@ -6021,6 +7327,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6038,18 +7346,20 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + client-only@0.0.1: {} clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -6057,12 +7367,12 @@ snapshots: codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/commands': 6.10.3 - '@codemirror/language': 6.12.3 - '@codemirror/lint': 6.9.6 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 '@codemirror/search': 6.7.0 - '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 color-convert@2.0.1: dependencies: @@ -6076,11 +7386,18 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@2.20.3: + optional: true + concat-map@0.0.1: {} - core-js@3.47.0: + convert-source-map@2.0.0: {} + + core-js@3.50.0: optional: true + country-region-data@4.1.0: {} + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -6094,9 +7411,16 @@ snapshots: utrie: 1.0.2 optional: true + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + cssesc@3.0.0: {} - csstype@3.1.3: {} + csstype@3.2.3: {} d3-array@3.2.4: dependencies: @@ -6104,9 +7428,16 @@ snapshots: d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} - d3-format@3.1.0: {} + d3-format@3.1.2: {} d3-interpolate@3.0.1: dependencies: @@ -6117,11 +7448,13 @@ snapshots: d3-scale@4.0.2: dependencies: d3-array: 3.2.4 - d3-format: 3.1.0 + d3-format: 3.1.2 d3-interpolate: 3.0.1 d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -6136,8 +7469,32 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6156,25 +7513,21 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - date-fns-jalali@4.1.0-0: {} + date-fns@4.4.0: {} - date-fns@4.1.0: {} - - debug@3.2.7(supports-color@7.2.0): + debug@3.2.7: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 - debug@4.4.3(supports-color@7.2.0): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 decimal.js-light@2.5.1: {} - decode-named-character-reference@1.2.0: + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -6208,12 +7561,11 @@ snapshots: dependencies: esutils: 2.0.3 - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.28.4 - csstype: 3.1.3 + dom-accessibility-api@0.5.16: {} - dompurify@3.4.12: + dom-accessibility-api@0.6.3: {} + + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 optional: true @@ -6224,11 +7576,25 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - embla-carousel-react@8.6.0(react@19.2.3): + echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.8): + dependencies: + echarts: 6.1.0 + fast-deep-equal: 3.1.3 + react: 19.2.8 + size-sensor: 1.0.3 + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + + electron-to-chromium@1.5.401: {} + + embla-carousel-react@8.6.0(react@19.2.8): dependencies: embla-carousel: 8.6.0 embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) - react: 19.2.3 + react: 19.2.8 embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): dependencies: @@ -6238,27 +7604,36 @@ snapshots: emoji-regex@9.2.2: {} - enhanced-resolve@5.18.3: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 - es-abstract@1.24.0: + entities@8.0.0: {} + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 data-view-buffer: 1.0.2 data-view-byte-length: 1.0.2 data-view-byte-offset: 1.0.1 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 get-symbol-description: 1.1.0 @@ -6267,7 +7642,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -6283,33 +7658,33 @@ snapshots: object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.7 - own-keys: 1.0.1 + own-keys: 1.0.2 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 + typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 + which-typed-array: 1.1.22 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.2.1: + es-iterator-helpers@1.4.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -6321,9 +7696,11 @@ snapshots: has-symbols: 1.1.0 internal-slot: 1.1.0 iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 + math-intrinsics: 1.1.0 - es-object-atoms@1.1.1: + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6332,150 +7709,170 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 - es-to-primitive@1.3.0: + es-to-primitive@1.3.4: dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.50.0: {} + + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} - eslint-config-next@15.5.6(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): + eslint-config-next@16.3.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@next/eslint-plugin-next': 15.5.6 - '@rushstack/eslint-patch': 1.14.1 - '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)) + '@next/eslint-plugin-next': 16.3.0(eslint@9.39.5(jiti@2.7.0)) + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) + globals: 16.4.0 + typescript-eslint: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: + - '@typescript-eslint/parser' - eslint-import-resolver-webpack - eslint-plugin-import-x - supports-color - eslint-import-resolver-node@0.3.9(supports-color@7.2.0): + eslint-import-resolver-node@0.3.10: dependencies: - debug: 3.2.7(supports-color@7.2.0) - is-core-module: 2.16.1 - resolve: 1.22.11 + debug: 3.2.7 + is-core-module: 2.16.2 + resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - get-tsconfig: 4.13.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + get-tsconfig: 4.14.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) - hasown: 2.0.2 - is-core-module: 2.16.1 + eslint: 9.39.5(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + hasown: 2.0.4 + is-core-module: 2.16.2 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 semver: 6.3.1 - string.prototype.trimend: 1.0.9 + string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.0 + axe-core: 4.13.0 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) - hasown: 2.0.2 + eslint: 9.39.5(jiti@2.7.0) + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + eslint: 9.39.5(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)): + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0) + es-iterator-helpers: 1.4.0 + eslint: 9.39.5(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.5 + resolve: 2.0.0-next.7 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -6485,29 +7882,31 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0): + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1(supports-color@7.2.0) + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1(supports-color@7.2.0) - '@eslint/js': 9.39.1 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.12.6 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -6518,21 +7917,21 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -6544,16 +7943,20 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} - eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} extend@3.0.2: {} fast-deep-equal@3.1.3: {} - fast-equals@5.3.2: {} - fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6562,14 +7965,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -6578,9 +7973,9 @@ snapshots: dependencies: '@types/pako': 2.0.4 iobuffer: 5.4.0 - pako: 2.1.0 + pako: 2.2.0 - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -6588,11 +7983,11 @@ snapshots: dependencies: format: 0.2.2 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.5 - fflate@0.8.2: {} + fflate@0.8.3: {} file-entry-cache@8.0.0: dependencies: @@ -6609,64 +8004,70 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.4: {} - follow-redirects@1.15.11(debug@4.4.3(supports-color@7.2.0)): - optionalDependencies: - debug: 4.4.3(supports-color@7.2.0) + follow-redirects@1.16.0: {} for-each@0.3.5: dependencies: is-callable: 1.2.7 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 format@0.2.2: {} - framer-motion@12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - motion-dom: 12.23.23 - motion-utils: 12.23.6 + motion-dom: 12.43.0 + motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + fsevents@2.3.3: + optional: true function-bind@1.1.2: {} - function.prototype.name@1.1.8: + function.prototype.name@1.2.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 - define-properties: 1.2.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 functions-have-names: 1.2.3 - hasown: 2.0.2 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 is-callable: 1.2.7 + is-document.all: 1.0.0 functions-have-names@1.2.3: {} generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -6674,7 +8075,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-symbol-description@1.1.0: dependencies: @@ -6682,7 +8083,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.14.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -6698,6 +8099,8 @@ snapshots: globals@14.0.0: {} + globals@16.4.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -6707,8 +8110,6 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -6727,35 +8128,35 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 hast-util-heading-rank@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 - hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): + hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) - property-information: 7.1.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 unist-util-position: 5.0.0 @@ -6765,33 +8166,45 @@ snapshots: hast-util-to-string@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-text@4.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + highlight.js@10.7.3: {} highlight.js@11.11.1: {} highlightjs-vue@1.0.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} html2canvas@1.4.1: @@ -6800,14 +8213,23 @@ snapshots: text-segmentation: 1.0.3 optional: true - icu-minify@4.13.3: + https-proxy-agent@5.0.1: dependencies: - '@formatjs/icu-messageformat-parser': 3.5.15 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + icu-minify@4.13.6: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.16 ignore@5.3.2: {} ignore@7.0.5: {} + immer@11.1.15: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -6815,25 +8237,27 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} - input-otp@1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + input-otp@1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 + hasown: 2.0.4 + side-channel: 1.1.1 internmap@2.0.3: {} - intl-messageformat@11.2.12: + intl-messageformat@11.2.13: dependencies: '@formatjs/fast-memoize': 3.1.7 - '@formatjs/icu-messageformat-parser': 3.5.15 + '@formatjs/icu-messageformat-parser': 3.5.16 iobuffer@5.4.0: {} @@ -6846,7 +8270,7 @@ snapshots: is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 @@ -6869,13 +8293,13 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.8.5 is-callable@1.2.7: {} - is-core-module@2.16.1: + is-core-module@2.16.2: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -6890,6 +8314,10 @@ snapshots: is-decimal@2.0.1: {} + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -6923,12 +8351,14 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-set@2.0.3: {} @@ -6949,7 +8379,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.19 + which-typed-array: 1.1.22 is-weakmap@2.0.2: {} @@ -6969,20 +8399,48 @@ snapshots: iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 has-symbols: 1.1.0 set-function-name: 2.0.2 - jiti@2.6.1: {} + jiti@2.7.0: {} js-tokens@4.0.0: {} - js-yaml@4.1.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.5 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -6993,15 +8451,17 @@ snapshots: dependencies: minimist: 1.2.8 + json5@2.2.3: {} + jspdf@4.2.1: dependencies: '@babel/runtime': 7.29.7 fast-png: 6.4.0 - fflate: 0.8.2 + fflate: 0.8.3 optionalDependencies: canvg: 3.0.11 - core-js: 3.47.0 - dompurify: 3.4.12 + core-js: 3.50.0 + dompurify: 3.4.13 html2canvas: 1.4.1 jsx-ast-utils@3.3.5: @@ -7026,54 +8486,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.30.2: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 locate-path@6.0.0: dependencies: @@ -7081,8 +8541,6 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.17.21: {} - longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -7096,13 +8554,21 @@ snapshots: lowlight@3.3.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 devlop: 1.1.0 highlight.js: 11.11.1 - lucide-react@0.552.0(react@19.2.3): + lru-cache@11.5.2: {} + + lru-cache@5.1.1: dependencies: - react: 19.2.3 + yallist: 3.1.1 + + lucide-react@1.28.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} magic-string@0.30.21: dependencies: @@ -7119,14 +8585,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.2(supports-color@7.2.0): + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -7144,75 +8610,75 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@7.2.0): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -7221,13 +8687,13 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -7239,14 +8705,14 @@ snapshots: mdast-util-to-hast@13.2.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.3 devlop: 1.1.0 micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 mdast-util-to-markdown@2.1.2: @@ -7258,18 +8724,20 @@ snapshots: mdast-util-to-string: 4.0.0 micromark-util-classify-character: 2.0.1 micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 zwitch: 2.0.4 mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.3: dependencies: - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-factory-destination: 2.0.1 micromark-factory-label: 2.0.1 @@ -7402,7 +8870,7 @@ snapshots: micromark-util-decode-string@2.0.1: dependencies: - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 micromark-util-character: 2.1.1 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.1 @@ -7436,11 +8904,11 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@7.2.0): + micromark@4.0.2: dependencies: - '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@7.2.0) - decode-named-character-reference: 1.2.0 + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 micromark-factory-space: 2.0.1 @@ -7461,7 +8929,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -7469,33 +8937,35 @@ snapshots: dependencies: mime-db: 1.52.0 - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 + min-indent@1.0.1: {} - minimatch@9.0.5: + minimatch@10.2.6: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 minimist@1.2.8: {} - motion-dom@12.23.23: + motion-dom@12.43.0: dependencies: - motion-utils: 12.23.6 + motion-utils: 12.39.0 - motion-utils@12.23.6: {} + motion-utils@12.39.0: {} - motion@12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - framer-motion: 12.23.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tslib: 2.8.1 optionalDependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) ms@2.1.3: {} - nanoid@3.3.11: {} + nanoid@3.3.17: {} napi-postinstall@0.3.4: {} @@ -7503,57 +8973,67 @@ snapshots: negotiator@1.0.0: {} - next-intl-swc-plugin-extractor@4.13.3: {} + next-intl-swc-plugin-extractor@4.13.6: {} - next-intl@4.13.3(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + next-intl@4.13.6(next@16.3.0(@babel/core@7.29.7)(@types/node@26.1.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@formatjs/intl-localematcher': 0.8.13 '@parcel/watcher': 2.6.0 - '@swc/core': 1.15.46 - icu-minify: 4.13.3 + '@swc/core': 1.16.0 + icu-minify: 4.13.6 negotiator: 1.0.0 - next: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - next-intl-swc-plugin-extractor: 4.13.3 - po-parser: 2.1.1 - react: 19.2.3 - use-intl: 4.13.3(react@19.2.3) + next: 16.3.0(@babel/core@7.29.7)(@types/node@26.1.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-intl-swc-plugin-extractor: 4.13.6 + po-parser: 2.2.0 + react: 19.2.8 + use-intl: 4.13.6(react@19.2.8) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@swc/helpers' - next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) - next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.3.0(@babel/core@7.29.7)(@types/node@26.1.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.2.7 + '@next/env': 16.3.0 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.34 - caniuse-lite: 1.0.30001753 - postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + baseline-browser-mapping: 2.11.12 + caniuse-lite: 1.0.30001806 + postcss: 8.5.23 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.7 - '@next/swc-darwin-x64': 16.2.7 - '@next/swc-linux-arm64-gnu': 16.2.7 - '@next/swc-linux-arm64-musl': 16.2.7 - '@next/swc-linux-x64-gnu': 16.2.7 - '@next/swc-linux-x64-musl': 16.2.7 - '@next/swc-win32-arm64-msvc': 16.2.7 - '@next/swc-win32-x64-msvc': 16.2.7 + '@next/swc-darwin-arm64': 16.3.0 + '@next/swc-darwin-x64': 16.3.0 + '@next/swc-linux-arm64-gnu': 16.3.0 + '@next/swc-linux-arm64-musl': 16.3.0 + '@next/swc-linux-x64-gnu': 16.3.0 + '@next/swc-linux-x64-musl': 16.3.0 + '@next/swc-win32-arm64-msvc': 16.3.0 + '@next/swc-win32-x64-msvc': 16.3.0 babel-plugin-react-compiler: 1.0.0 - sharp: 0.34.5 + sharp: 0.35.3(@types/node@26.1.2) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros node-addon-api@7.1.1: {} + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.52: {} + normalize-wheel@1.0.1: {} object-assign@4.1.1: {} @@ -7564,39 +9044,41 @@ snapshots: object.assign@4.1.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 has-symbols: 1.1.0 object-keys: 1.1.1 object.entries@1.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 object.fromentries@2.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 object.groupby@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 object.values@1.2.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 + + obug@2.1.4: {} optionator@0.9.4: dependencies: @@ -7607,8 +9089,9 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - own-keys@1.0.1: + own-keys@1.0.2: dependencies: + call-bound: 1.0.4 get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 @@ -7621,7 +9104,7 @@ snapshots: dependencies: p-limit: 3.1.0 - pako@2.1.0: {} + pako@2.2.0: {} parent-module@1.0.1: dependencies: @@ -7632,31 +9115,35 @@ snapshots: '@types/unist': 2.0.11 character-entities-legacy: 3.0.0 character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 is-alphanumerical: 2.0.1 is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + pathe@2.0.3: {} + performance-now@2.1.0: optional: true picocolors@1.1.1: {} - picomatch@2.3.1: {} - - picomatch@4.0.3: {} + picomatch@2.3.2: {} picomatch@4.0.5: {} pinyin-match@1.2.10: {} - po-parser@2.1.1: {} + po-parser@2.2.0: {} possible-typed-array-names@1.1.0: {} @@ -7665,20 +9152,26 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.4.31: + postcss@8.5.23: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.6: + postcss@8.5.25: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} prop-types@15.8.1: @@ -7687,7 +9180,7 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - property-information@7.1.0: {} + property-information@7.2.0: {} proxy-from-env@2.1.0: {} @@ -7695,211 +9188,219 @@ snapshots: queue-microtask@1.2.3: {} - radix-ui@1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + radix-ui@1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.3) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-accessible-icon': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-hover-card': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menubar': 1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-one-time-password-field': 0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-password-toggle-field': 0.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-radio-group': 1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-select': 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slider': 1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-switch': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toast': 1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 - '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) raf@3.4.1: dependencies: performance-now: 2.1.0 optional: true - react-day-picker@9.11.1(react@19.2.3): + react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8): dependencies: - '@date-fns/tz': 1.4.1 - date-fns: 4.1.0 - date-fns-jalali: 4.1.0-0 - react: 19.2.3 + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 - react-dom@19.2.3(react@19.2.3): + react-dom@19.2.8(react@19.2.8): dependencies: - react: 19.2.3 + react: 19.2.8 scheduler: 0.27.0 - react-easy-crop@5.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-easy-crop@6.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: normalize-wheel: 1.0.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - tslib: 2.8.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) - react-hook-form@7.68.0(react@19.2.3): + react-hook-form@7.84.0(react@19.2.8): dependencies: - react: 19.2.3 + react: 19.2.8 - react-image-crop@11.0.10(react@19.2.3): + react-image-crop@11.1.2(react@19.2.8): dependencies: - react: 19.2.3 + react: 19.2.8 react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} - react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.3)(supports-color@7.2.0): + react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8): dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@types/react': 19.2.2 + '@types/react': 19.2.18 devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.3 - remark-parse: 11.0.0(supports-color@7.2.0) + react: 19.2.8 + remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 transitivePeerDependencies: - supports-color - react-remove-scroll-bar@2.3.8(@types/react@19.2.2)(react@19.2.3): + react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): dependencies: - react: 19.2.3 - react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.3) + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - react-remove-scroll@2.7.1(@types/react@19.2.2)(react@19.2.3): + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.3 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.2)(react@19.2.3) - react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.3) + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.2)(react@19.2.3) - use-sidecar: 1.1.3(@types/react@19.2.2)(react@19.2.3) + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - fast-equals: 5.3.2 - prop-types: 15.8.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-transition-group: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - - react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.2.3): + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): dependencies: get-nonce: 1.0.1 - react: 19.2.3 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - react-syntax-highlighter@16.1.0(react@19.2.3): + react-syntax-highlighter@16.1.1(react@19.2.8): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 highlight.js: 10.7.3 highlightjs-vue: 1.0.0 lowlight: 1.20.0 prismjs: 1.30.0 - react: 19.2.3 + react: 19.2.8 refractor: 5.0.0 - react-transition-group@4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - '@babel/runtime': 7.28.4 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react@19.2.8: {} - react@19.2.3: {} - - recharts-scale@0.4.5: - dependencies: - decimal.js-light: 2.5.1 - - recharts@2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@18.3.1)(react@19.2.8)(redux@5.0.1): dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) clsx: 2.1.1 - eventemitter3: 4.0.7 - lodash: 4.17.21 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + decimal.js-light: 2.5.1 + es-toolkit: 1.50.0 + eventemitter3: 5.0.4 + immer: 11.1.15 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - recharts-scale: 0.4.5 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + reselect: 5.2.0 tiny-invariant: 1.3.3 - victory-vendor: 36.9.2 + use-sync-external-store: 1.6.0(react@19.2.8) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 refractor@5.0.0: dependencies: - '@types/hast': 3.0.4 - '@types/prismjs': 1.26.5 + '@types/hast': 3.0.5 + '@types/prismjs': 1.26.6 hastscript: 9.0.1 parse-entities: 4.0.2 @@ -7908,7 +9409,7 @@ snapshots: regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -7917,35 +9418,35 @@ snapshots: rehype-highlight@7.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-text: 4.0.2 lowlight: 3.3.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 rehype-slug@6.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 github-slugger: 2.0.0 hast-util-heading-rank: 3.0.0 hast-util-to-string: 3.0.1 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 - remark-gfm@4.0.1(supports-color@7.2.0): + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@7.2.0) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@7.2.0) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@7.2.0): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -7953,7 +9454,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 @@ -7965,19 +9466,20 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: + resolve@2.0.0-next.7: dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - resolve@2.0.0-next.5: - dependencies: - is-core-module: 2.16.1 + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.2 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -7986,13 +9488,34 @@ snapshots: rgbcolor@1.0.1: optional: true + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.3: + safe-array-concat@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -8009,11 +9532,15 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} - semver@7.7.3: {} + semver@7.8.5: {} set-function-length@1.2.2: dependencies: @@ -8035,38 +9562,40 @@ snapshots: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 - sharp@0.34.5: + sharp@0.35.3(@types/node@26.1.2): dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 26.1.2 optional: true shebang-command@2.0.0: @@ -8075,7 +9604,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -8095,28 +9624,45 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + siginfo@2.0.0: {} + + size-sensor@1.0.3: {} + + sonner@2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + optional: true + + source-map@0.6.1: + optional: true + space-separated-tokens@2.0.2: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + stackblur-canvas@2.7.0: optional: true + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8124,53 +9670,54 @@ snapshots: string.prototype.includes@2.0.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 string.prototype.matchall@4.0.12: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 gopd: 1.2.0 has-symbols: 1.1.0 internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.0 + side-channel: 1.1.1 string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 - string.prototype.trim@1.2.10: + string.prototype.trim@1.2.11: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 - string.prototype.trimend@1.0.9: + string.prototype.trimend@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 stringify-entities@4.0.4: dependencies: @@ -8179,6 +9726,10 @@ snapshots: strip-bom@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} style-mod@4.1.3: {} @@ -8191,10 +9742,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): dependencies: client-only: 0.0.1 - react: 19.2.3 + react: 19.2.8 + optionalDependencies: + '@babel/core': 7.29.7 supports-color@7.2.0: dependencies: @@ -8205,11 +9758,21 @@ snapshots: svg-pathdata@6.0.3: optional: true - tailwind-merge@3.3.1: {} + symbol-tree@3.2.4: {} - tailwindcss@4.1.16: {} + tailwind-merge@3.6.0: {} - tapable@2.3.0: {} + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + terser@5.49.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true text-segmentation@1.0.3: dependencies: @@ -8218,22 +9781,42 @@ snapshots: tiny-invariant@1.3.3: {} - tinyglobby@0.2.15: + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 tsconfig-paths@3.15.0: dependencies: @@ -8242,6 +9825,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.3.0: {} + tslib@2.8.1: {} tw-animate-css@1.4.0: {} @@ -8258,7 +9843,7 @@ snapshots: typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -8267,23 +9852,34 @@ snapshots: typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - typed-array-length@1.0.7: + typed-array-length@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript@5.9.3: {} + typescript-eslint@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} unbox-primitive@1.1.0: dependencies: @@ -8292,7 +9888,9 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.21.0: {} + undici-types@8.3.0: {} + + undici@8.10.0: {} unified@11.0.5: dependencies: @@ -8326,66 +9924,75 @@ snapshots: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - unist-util-visit@5.0.0: + unist-util-visit@5.1.0: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unrs-resolver@1.11.1: + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 uri-js@4.4.1: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.2)(react@19.2.3): + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.3 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - use-intl@4.13.3(react@19.2.3): + use-intl@4.13.6(react@19.2.8): dependencies: '@formatjs/fast-memoize': 3.1.7 '@schummar/icu-type-parser': 1.21.5 - icu-minify: 4.13.3 - intl-messageformat: 11.2.12 - react: 19.2.3 + icu-minify: 4.13.6 + intl-messageformat: 11.2.13 + react: 19.2.8 - use-sidecar@1.1.3(@types/react@19.2.2)(react@19.2.3): + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8): dependencies: detect-node-es: 1.1.0 - react: 19.2.3 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.18 - use-sync-external-store@1.6.0(react@19.2.3): + use-sync-external-store@1.6.0(react@19.2.8): dependencies: - react: 19.2.3 + react: 19.2.8 util-deprecate@1.0.2: {} @@ -8394,11 +10001,11 @@ snapshots: base64-arraybuffer: 1.0.2 optional: true - vaul@1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + vaul@1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -8413,13 +10020,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - victory-vendor@36.9.2: + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 '@types/d3-ease': 3.0.2 '@types/d3-interpolate': 3.0.4 '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.7 + '@types/d3-shape': 3.1.8 '@types/d3-time': 3.0.4 '@types/d3-timer': 3.0.2 d3-array: 3.2.4 @@ -8430,8 +10037,73 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.49.1 + + vitest@4.1.10(@types/node@26.1.2)(jsdom@30.0.1)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.4(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -8443,7 +10115,7 @@ snapshots: which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 - function.prototype.name: 1.1.8 + function.prototype.name: 1.2.0 has-tostringtag: 1.0.2 is-async-function: 2.1.1 is-date-object: 1.1.0 @@ -8454,7 +10126,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.19 + which-typed-array: 1.1.22 which-collection@1.0.2: dependencies: @@ -8463,10 +10135,10 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.19: + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -8477,10 +10149,37 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + yocto-queue@0.1.0: {} - zod@4.1.12: {} + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 + + zustand@4.5.7(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + immer: 11.1.15 + react: 19.2.8 zwitch@2.0.4: {} diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 62ad2454..f9cc8581 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -73,105 +73,15 @@ if (typeof setInterval !== 'undefined') { }, 60000); } -function backendOrigin(): string { - return process.env.WAVELET_BACKEND_URL || 'http://localhost:8000'; -} - -function isMultipart(request: NextRequest): boolean { - return (request.headers.get('content-type') || '').includes( - 'multipart/form-data', - ); -} - -function isDocumentNavigation(request: NextRequest): boolean { - return request.headers.get('sec-fetch-dest') === 'document'; -} - -async function proxyApiToBackend(request: NextRequest): Promise { - const { pathname, search } = request.nextUrl; - const url = `${backendOrigin()}${pathname}${search}`; - const headers = new Headers(); - request.headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if ( - lower === 'host' || - lower === 'connection' || - lower === 'content-length' || - lower === 'transfer-encoding' - ) { - return; - } - headers.set(key, value); - }); - const host = request.headers.get('host'); - if (host) { - headers.set('x-forwarded-host', host); - } - headers.set( - 'x-forwarded-proto', - request.nextUrl.protocol.replace(':', '') || 'http', - ); - - const method = request.method.toUpperCase(); - const init: RequestInit = { - method, - headers, - redirect: 'manual', - }; - if (method !== 'GET' && method !== 'HEAD') { - init.body = request.body; - Object.assign(init, { duplex: 'half' }); - } - - const upstream = await fetch(url, init); - - if (isDocumentNavigation(request)) { - if (upstream.status === 401) { - const loginUrl = new URL('/login', request.url); - loginUrl.searchParams.set('callbackUrl', '/home'); - return NextResponse.redirect(loginUrl); - } - if (upstream.status === 403) { - return NextResponse.redirect(new URL('/403', request.url)); - } - } - - const responseHeaders = new Headers(); - upstream.headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'set-cookie' || lower === 'transfer-encoding') { - return; - } - responseHeaders.set(key, value); - }); - const out = new NextResponse(upstream.body, { - status: upstream.status, - headers: responseHeaders, - }); - const cookies = - typeof upstream.headers.getSetCookie === 'function' - ? upstream.headers.getSetCookie() - : []; - for (const cookie of cookies) { - out.headers.append('set-cookie', cookie); - } - return out; -} - /* ==================== 代理主函数 ==================== */ -export async function proxy(request: NextRequest) { +export function proxy(request: NextRequest) { const { pathname, search } = request.nextUrl; const sessionCookieName = - process.env.WAVELET_SESSION_COOKIE_NAME || 'wavelet_session_id'; + process.env.WAVELET_SESSION_COOKIE_NAME || 'openflare_session_id'; const sessionCookie = request.cookies.get(sessionCookieName); - // WebSocket upgrades must pass through to rewrites untouched. - if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') { - return NextResponse.next(); - } - - /* API 请求:速率限制后反代,并原样回写 Set-Cookie(rewrites 会丢登录 Cookie) */ + /* API 请求:速率限制后放行 */ if (pathname.startsWith('/api/')) { const rateLimitEnabled = process.env.WAVELET_RATE_LIMIT_ENABLED === 'true'; @@ -193,40 +103,13 @@ export async function proxy(request: NextRequest) { ); } } - if (isMultipart(request)) { - return NextResponse.next(); - } - try { - return await proxyApiToBackend(request); - } catch { - return NextResponse.json( - { error_msg: '无法连接到服务器', data: null }, - { status: 502 }, - ); - } - } - - /* 页面请求:公共路由放行 */ - const publicRoutes = [ - '/', - '/login', - '/register', - '/callback', - '/privacy', - '/terms', - '/icon', - '/403', - ]; - const publicPrefixes = ['/docs/', '/epay/']; - - if ( - publicRoutes.includes(pathname) || - publicPrefixes.some((p) => pathname.startsWith(p)) - ) { return NextResponse.next(); } - if (!sessionCookie) { + /* 页面请求:默认私域,仅登录流程入口无需 session */ + const authEntryRoutes = ['/login', '/register', '/callback']; + + if (!authEntryRoutes.includes(pathname) && !sessionCookie) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('callbackUrl', pathname + search); return NextResponse.redirect(loginUrl); @@ -237,7 +120,7 @@ export async function proxy(request: NextRequest) { export const config = { matcher: [ - '/api/((?!v1/admin/logs/ws).*)', - '/((?!_next|favicon.ico|robots.txt|sitemap.xml|api/v1/admin/logs/ws|.*\\.(?:jpg|jpeg|gif|png|svg|ico|webp)).*)', + '/api/:path*', + '/((?!_next|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:jpg|jpeg|gif|png|svg|ico|webp)).*)', ], }; diff --git a/frontend/public/style/default.css b/frontend/public/style/default.css index 94980532..33a77ebb 100644 --- a/frontend/public/style/default.css +++ b/frontend/public/style/default.css @@ -136,7 +136,7 @@ } @theme inline { - /* + /* * Aurora 动画配置 */ --animate-aurora: aurora 120s linear infinite; @@ -154,4 +154,4 @@ 350% 50%; } } -} +} \ No newline at end of file diff --git a/frontend/scripts/generate-themes.js b/frontend/scripts/generate-themes.js index ec36ce7f..d33eb02e 100644 --- a/frontend/scripts/generate-themes.js +++ b/frontend/scripts/generate-themes.js @@ -68,7 +68,11 @@ try { fs.mkdirSync(outputDir, { recursive: true }); } - fs.writeFileSync(OUTPUT_FILE, JSON.stringify(themes, null, 2), 'utf-8'); + fs.writeFileSync( + OUTPUT_FILE, + JSON.stringify(themes, null, 2) + '\n', + 'utf-8', + ); console.log(`Successfully generated themes.json at ${OUTPUT_FILE}`); } catch (error) { console.error('Failed to generate themes.json:', error); diff --git a/frontend/scripts/merge-i18n-fragments.mjs b/frontend/scripts/merge-i18n-fragments.mjs new file mode 100644 index 00000000..26970c36 --- /dev/null +++ b/frontend/scripts/merge-i18n-fragments.mjs @@ -0,0 +1,42 @@ +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const fragmentsDir = resolve(root, 'messages/fragments'); + +function deepMerge(target, source) { + for (const [key, value] of Object.entries(source)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + target[key] && + typeof target[key] === 'object' && + !Array.isArray(target[key]) + ) { + deepMerge(target[key], value); + } else { + target[key] = value; + } + } + return target; +} + +function mergeLocale(locale) { + const mainPath = resolve(root, `messages/${locale}.json`); + // 主包是纯生成物:从空对象开始合并全部 fragment(fragments 为唯一源) + const data = {}; + const suffix = `.${locale}.json`; + for (const name of readdirSync(fragmentsDir).sort()) { + if (!name.endsWith(suffix)) continue; + const frag = JSON.parse(readFileSync(resolve(fragmentsDir, name), 'utf8')); + deepMerge(data, frag); + console.log(`merged ${name}`); + } + writeFileSync(mainPath, `${JSON.stringify(data, null, 2)}\n`); +} + +mergeLocale('zh-CN'); +mergeLocale('en'); +console.log('fragments merged'); diff --git a/frontend/tests/a11y.test.tsx b/frontend/tests/a11y.test.tsx new file mode 100644 index 00000000..ad98773e --- /dev/null +++ b/frontend/tests/a11y.test.tsx @@ -0,0 +1,239 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import axe from 'axe-core'; + +import { LoginPage } from '@/components/auth/login-page'; +import { RegisterPage } from '@/components/auth/register-page'; +import { OTPForm } from '@/components/auth/otp-form'; +import { CapWidget } from '@/components/auth/cap-widget'; +import { OtherTab } from '@/components/common/settings/other-tab'; +import { SecurityTab } from '@/components/common/settings/security-tab'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { UserProvider } from '@/contexts/user-context'; +import type { SystemConfig } from '@/lib/services/admin'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useSearchParams: () => new URLSearchParams(''), +})); + +const { + getUserInfoMock, + getPublicConfigMock, + getAuthSourcesMock, + listAuthSourcesMock, +} = vi.hoisted(() => ({ + getUserInfoMock: vi.fn(), + getPublicConfigMock: vi.fn(), + getAuthSourcesMock: vi.fn(), + listAuthSourcesMock: vi.fn(), +})); + +vi.mock('@/lib/services/auth', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AuthService: { + ...actual.AuthService, + getUserInfo: getUserInfoMock, + getAuthSources: getAuthSourcesMock, + }, + }; +}); +vi.mock('@/lib/services', () => ({ + default: { + adminAuthSource: { listAuthSources: listAuthSourcesMock }, + adminSystemConfig: { + updateSystemConfig: vi.fn().mockResolvedValue(undefined), + }, + }, +})); + +vi.mock('@/lib/cap-solver', () => ({ + getCapToken: vi.fn().mockResolvedValue('test-cap-token'), +})); + +vi.mock('@/lib/services/config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ConfigService: { + ...actual.ConfigService, + getPublicConfig: getPublicConfigMock, + }, + }; +}); + +async function runAxe(container: HTMLElement) { + const results = await axe.run(container, { + rules: { + // jsdom 无布局引擎,颜色对比度规则无法评估 + 'color-contrast': { enabled: false }, + }, + }); + return results.violations; +} + +function renderWithProviders(page: React.ReactNode) { + return render( + + + {page} + + , + ); +} + +describe('a11y(axe-core 结构性规则)', () => { + beforeEach(() => { + getUserInfoMock.mockReset(); + getUserInfoMock.mockResolvedValue(null); + getPublicConfigMock.mockReset(); + getPublicConfigMock.mockResolvedValue({ + registration_enabled: 'true', + password_register_enabled: 'true', + }); + getAuthSourcesMock.mockReset(); + getAuthSourcesMock.mockResolvedValue([]); + listAuthSourcesMock.mockReset(); + listAuthSourcesMock.mockResolvedValue([]); + }); + + it('登录页表单无 axe 违规', async () => { + renderWithProviders(); + + await screen.findByRole('button', { name: /登录|submit|登录/ }); + expect(await runAxe(document.body)).toEqual([]); + }); + + it('注册页表单无 axe 违规', async () => { + renderWithProviders(); + + await screen.findByRole('button', { name: /创建账号|submit/ }); + expect(await runAxe(document.body)).toEqual([]); + }); + + it('登录 OTP 验证表单无 axe 违规', async () => { + renderWithProviders( +
+ {}} + loginCodeTip='验证码已发送至邮箱' + loginCooldown={0} + isPending={false} + onResend={() => {}} + onSubmit={() => {}} + /> +
, + ); + + await screen.findByRole('button', { name: /重新发送/ }); + expect(await runAxe(document.body)).toEqual([]); + }); + + it('人机验证小部件(手动模式)无 axe 违规', async () => { + renderWithProviders( +
+ {}} /> +
, + ); + + await screen.findByRole('button'); + expect(await runAxe(document.body)).toEqual([]); + }); + + it('注册页(开启人机验证)无 axe 违规', async () => { + getPublicConfigMock.mockResolvedValue({ + registration_enabled: 'true', + password_register_enabled: 'true', + cap_login_enabled: 'true', + cap_auto_solve: 'true', + }); + renderWithProviders(); + + await screen.findByRole('button', { name: /创建账号|submit/ }); + // CAPTCHA 自动求解完成后进入已通过状态 + await screen.findByText('人机验证通过'); + expect(await runAxe(document.body)).toEqual([]); + }); +}); + +it('设置-安全 Tab(人机验证配置表单)无 axe 违规', async () => { + const mk = (key: string, value: string): SystemConfig => ({ + key, + value, + type: 'system', + visibility: 0, + description: '', + created_at: '', + updated_at: '', + }); + const configs = { + cap_login_enabled: mk('cap_login_enabled', 'true'), + cap_challenge_count: mk('cap_challenge_count', '1'), + cap_challenge_difficulty: mk('cap_challenge_difficulty', '4'), + cap_challenge_size: mk('cap_challenge_size', '32'), + cap_challenge_ttl_seconds: mk('cap_challenge_ttl_seconds', '600'), + cap_token_ttl_seconds: mk('cap_token_ttl_seconds', '1200'), + cap_auto_solve: mk('cap_auto_solve', 'true'), + login_session_ttl_hours: mk('login_session_ttl_hours', '168'), + }; + + renderWithProviders( +
+ + } + /> +
, + ); + + await screen.findByLabelText(/难题数量/); + expect(await runAxe(document.body)).toEqual([]); +}); + +it('设置-其他 Tab(菜单显示配置开关列表)无 axe 违规', async () => { + const mk = (key: string, value: string): SystemConfig => ({ + key, + value, + type: 'system', + visibility: 0, + description: '', + created_at: '', + updated_at: '', + }); + const configs = { + menu_display_config: mk( + 'menu_display_config', + JSON.stringify({ '/nodes': true, '/websites': false }), + ), + }; + + renderWithProviders( +
+ +
, + ); + + await screen.findByRole('switch', { name: /节点/ }); + expect(await runAxe(document.body)).toEqual([]); +}); diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx new file mode 100644 index 00000000..9f044d3d --- /dev/null +++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx @@ -0,0 +1,114 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CloudflareGroupDetailPageClient } from '@/app/(main)/cloudflare/groups/[id]/page-client'; +import { CloudflareService, NodeService } from '@/lib/services/openflare'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('next/navigation', () => ({ + useParams: () => ({ id: '7' }), +})); + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + CloudflareService: { + ...actual.CloudflareService, + getGroup: vi.fn(), + listAvailableDomains: vi.fn(), + updateGroup: vi.fn(), + createMember: vi.fn(), + updateMember: vi.fn(), + syncMember: vi.fn(), + removeMember: vi.fn(), + }, + NodeService: { ...actual.NodeService, listNodes: vi.fn() }, + }; +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + render( + + + + + , + ); +} + +describe('Cloudflare group detail refresh', () => { + beforeEach(() => { + vi.mocked(CloudflareService.getGroup).mockReset(); + vi.mocked(CloudflareService.listAvailableDomains).mockReset(); + vi.mocked(NodeService.listNodes).mockReset(); + vi.mocked(CloudflareService.getGroup).mockResolvedValue({ + group: { + id: 7, + name: '生产节点', + primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, + backup_node: null, + active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, + default_proxied: true, + enabled: true, + member_count: 0, + created_at: '', + updated_at: '', + }, + members: [], + }); + vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]); + vi.mocked(NodeService.listNodes).mockResolvedValue([]); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('refreshes detail data when the refresh button is clicked', async () => { + renderPage(); + + expect( + await screen.findByRole('heading', { name: '生产节点' }), + ).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + + await waitFor(() => { + expect(CloudflareService.getGroup).toHaveBeenCalledTimes(2); + }); + }); + + it('automatically refreshes detail data every five seconds', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderPage(); + + expect( + await screen.findByRole('heading', { name: '生产节点' }), + ).toBeVisible(); + expect(CloudflareService.getGroup).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + await waitFor(() => { + expect(CloudflareService.getGroup).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/frontend/tests/cloudflare/cloudflare-overview.test.tsx b/frontend/tests/cloudflare/cloudflare-overview.test.tsx new file mode 100644 index 00000000..8248603a --- /dev/null +++ b/frontend/tests/cloudflare/cloudflare-overview.test.tsx @@ -0,0 +1,132 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import CloudflarePage from '@/app/(main)/cloudflare/page'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; +import { AdminTaskService } from '@/lib/services/admin'; +import { CloudflareService, NodeService } from '@/lib/services/openflare'; + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + CloudflareService: { + ...actual.CloudflareService, + getOverview: vi.fn(), + listGroups: vi.fn(), + createGroup: vi.fn(), + syncGroup: vi.fn(), + deleteGroup: vi.fn(), + }, + NodeService: { ...actual.NodeService, listNodes: vi.fn() }, + }; +}); + +vi.mock('@/lib/services/admin', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AdminTaskService: { + ...actual.AdminTaskService, + listTaskExecutions: vi.fn(), + getTaskExecution: vi.fn(), + retryTaskExecution: vi.fn(), + }, + }; +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + render( + + + + + , + ); +} + +describe('Cloudflare overview', () => { + beforeEach(() => { + vi.mocked(CloudflareService.listGroups).mockResolvedValue([]); + vi.mocked(NodeService.listNodes).mockResolvedValue([]); + vi.mocked(AdminTaskService.listTaskExecutions).mockResolvedValue({ + items: [], + total: 0, + page: 1, + page_size: 10, + }); + vi.mocked(CloudflareService.getOverview).mockResolvedValue({ + connection: { + configured: false, + ready: false, + source: '', + dns_account_id: null, + status: '', + verified_at: null, + }, + group_count: 0, + member_count: 0, + ok_count: 0, + pending_count: 0, + error_count: 0, + }); + }); + + it('shows the token readiness gate without the synchronization boundary card', async () => { + renderPage(); + + expect(await screen.findByText('Cloudflare 连接尚未就绪')).toBeVisible(); + expect(screen.queryByText('同步边界')).not.toBeInTheDocument(); + }); + + it('renders pointing groups directly on the overview page', async () => { + vi.mocked(CloudflareService.getOverview).mockResolvedValue({ + connection: { + configured: true, + ready: true, + source: 'standalone', + dns_account_id: null, + status: 'ready', + verified_at: null, + }, + group_count: 1, + member_count: 2, + ok_count: 1, + pending_count: 1, + error_count: 0, + }); + vi.mocked(CloudflareService.listGroups).mockResolvedValue([ + { + id: 7, + name: '生产节点', + primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, + backup_node: null, + active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, + default_proxied: true, + enabled: true, + member_count: 2, + created_at: '', + updated_at: '', + }, + ]); + + renderPage(); + + expect(await screen.findByText('生产节点')).toBeVisible(); + expect(screen.getByRole('link', { name: '管理' })).toHaveAttribute( + 'href', + '/cloudflare/groups/7', + ); + expect(await screen.findByText('同步任务')).toBeVisible(); + }); +}); diff --git a/frontend/tests/cloudflare/cloudflare-service.test.ts b/frontend/tests/cloudflare/cloudflare-service.test.ts new file mode 100644 index 00000000..82e26bf7 --- /dev/null +++ b/frontend/tests/cloudflare/cloudflare-service.test.ts @@ -0,0 +1,58 @@ +import type { AxiosResponse } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import apiClient from '@/lib/services/core/api-client'; +import { CloudflareService } from '@/lib/services/openflare/cloudflare.service'; + +vi.mock('@/lib/services/core/api-client', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, +})); + +function response(data: T) { + return { + data: { error_msg: '', data }, + status: 200, + statusText: 'OK', + headers: {}, + config: { headers: {} }, + } as AxiosResponse; +} + +describe('CloudflareService', () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + vi.mocked(apiClient.post).mockReset(); + vi.mocked(apiClient.put).mockReset(); + }); + + it('uses the Cloudflare management API paths', async () => { + vi.mocked(apiClient.get).mockResolvedValue(response([])); + vi.mocked(apiClient.post).mockResolvedValue( + response({ task_id: 'task-1' }), + ); + vi.mocked(apiClient.put).mockResolvedValue(response({ configured: true })); + + await CloudflareService.saveConnection({ + source: 'standalone', + dns_account_id: 0, + api_token: 'secret', + }); + await CloudflareService.listGroups(); + await CloudflareService.syncMember(7, 9); + + expect(apiClient.put).toHaveBeenCalledWith( + '/api/v1/d/cloudflare/connection', + expect.objectContaining({ source: 'standalone' }), + undefined, + ); + expect(apiClient.get).toHaveBeenCalledWith( + '/api/v1/d/cloudflare/groups', + expect.objectContaining({ params: undefined }), + ); + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/cloudflare/groups/7/members/9/sync', + undefined, + undefined, + ); + }); +}); diff --git a/frontend/tests/cloudflare/member-add-dialog.test.tsx b/frontend/tests/cloudflare/member-add-dialog.test.tsx new file mode 100644 index 00000000..594e700d --- /dev/null +++ b/frontend/tests/cloudflare/member-add-dialog.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MemberAddDialog } from '@/app/(main)/cloudflare/components/member-add-dialog'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; +import type { CloudflareAvailableDomain } from '@/lib/services/openflare'; + +const domains: CloudflareAvailableDomain[] = [ + { + id: 1, + zone_id: 10, + domain: 'example.com', + zone_domain: 'example.com', + }, + { + id: 2, + zone_id: 10, + domain: 'www.example.com', + zone_domain: 'example.com', + }, + { + id: 3, + zone_id: 10, + domain: 'api.example.com', + zone_domain: 'example.com', + }, + { + id: 4, + zone_id: 20, + domain: 'app.other.io', + zone_domain: 'other.io', + }, +]; + +describe('MemberAddDialog advanced domain picker', () => { + it('groups domains under top-level zone roots and supports batch select', () => { + const onSubmit = vi.fn(); + render( + + + , + ); + + expect(screen.getAllByText('example.com').length).toBeGreaterThan(0); + expect(screen.getAllByText('other.io').length).toBeGreaterThan(0); + expect(screen.getByText('www.example.com')).toBeVisible(); + expect(screen.getByText('api.example.com')).toBeVisible(); + + fireEvent.click(screen.getByRole('button', { name: '全选可见' })); + fireEvent.click(screen.getByRole('button', { name: '添加并同步(4)' })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const [ids, proxied] = onSubmit.mock.calls[0] as [number[], boolean]; + expect([...ids].sort((a, b) => a - b)).toEqual([1, 2, 3, 4]); + expect(proxied).toBe(true); + }); + + it('filters by keyword across domain and zone root', () => { + render( + + + , + ); + + fireEvent.change(screen.getByPlaceholderText('搜索域名或顶级域…'), { + target: { value: 'other' }, + }); + + expect(screen.queryByText('www.example.com')).not.toBeInTheDocument(); + expect(screen.getByText('app.other.io')).toBeVisible(); + expect(screen.getByText('other.io')).toBeVisible(); + }); + + it('selects an entire top-level domain group', () => { + const onSubmit = vi.fn(); + render( + + + , + ); + + fireEvent.click( + screen.getByRole('checkbox', { name: '选择顶级域 example.com' }), + ); + fireEvent.click(screen.getByRole('button', { name: '添加并同步(3)' })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const [ids, proxied] = onSubmit.mock.calls[0] as [number[], boolean]; + expect([...ids].sort((a, b) => a - b)).toEqual([1, 2, 3]); + expect(proxied).toBe(true); + }); + + it('toggles individual domains within a group', () => { + const onSubmit = vi.fn(); + render( + + + , + ); + + const wwwLabel = screen.getByText('www.example.com').closest('label'); + expect(wwwLabel).toBeTruthy(); + fireEvent.click(within(wwwLabel as HTMLElement).getByRole('checkbox')); + + fireEvent.click(screen.getByRole('button', { name: '添加并同步' })); + expect(onSubmit).toHaveBeenCalledWith([2], false); + }); +}); diff --git a/frontend/tests/cloudflare/sync-tasks-panel.test.tsx b/frontend/tests/cloudflare/sync-tasks-panel.test.tsx new file mode 100644 index 00000000..5d21a1cd --- /dev/null +++ b/frontend/tests/cloudflare/sync-tasks-panel.test.tsx @@ -0,0 +1,153 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SyncTasksPanel } from '@/app/(main)/cloudflare/components/sync-tasks-panel'; +import { AdminTaskService } from '@/lib/services/admin'; +import type { TaskExecution } from '@/lib/services/admin'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('@/lib/services/admin', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AdminTaskService: { + ...actual.AdminTaskService, + listTaskExecutions: vi.fn(), + getTaskExecution: vi.fn(), + retryTaskExecution: vi.fn(), + }, + }; +}); + +function renderPanel() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + render( + + + + + , + ); +} + +function execution( + partial: Partial & + Pick, +): TaskExecution { + return { + task_id: `task_${partial.id}`, + status: 'succeeded', + retryable: true, + max_retry: 2, + retry_count: 0, + log: '', + error_message: '', + result: 'ok', + started_at: null, + finished_at: null, + duration: 100, + payload: '{}', + triggered_by: 'manual', + created_at: '2026-08-04T10:00:00Z', + updated_at: '2026-08-04T10:00:01Z', + ...partial, + }; +} + +describe('Cloudflare sync tasks panel', () => { + beforeEach(() => { + vi.mocked(AdminTaskService.listTaskExecutions).mockReset(); + }); + + it('queries only sync_member and sync_group and hides other tasks', async () => { + vi.mocked(AdminTaskService.listTaskExecutions).mockImplementation( + async (request = {}) => { + if (request.task_type === 'cloudflare:sync_member') { + return { + items: [ + execution({ + id: '1', + task_type: 'cloudflare:sync_member', + task_name: 'Cloudflare 域名同步', + created_at: '2026-08-04T12:00:00Z', + }), + ], + total: 1, + page: 1, + page_size: 50, + }; + } + if (request.task_type === 'cloudflare:sync_group') { + return { + items: [ + execution({ + id: '2', + task_type: 'cloudflare:sync_group', + task_name: 'Cloudflare 分组同步', + created_at: '2026-08-04T11:00:00Z', + }), + // Defensive: even if API returns unrelated rows, panel must drop them. + execution({ + id: '99', + task_type: 'system:cleanup', + task_name: '系统垃圾清理', + triggered_by: 'schedule', + created_at: '2026-08-04T13:00:00Z', + }), + ], + total: 2, + page: 1, + page_size: 50, + }; + } + return { items: [], total: 0, page: 1, page_size: 50 }; + }, + ); + + renderPanel(); + + expect(await screen.findByText('Cloudflare 域名同步')).toBeVisible(); + expect(screen.getByText('Cloudflare 分组同步')).toBeVisible(); + expect(screen.queryByText('系统垃圾清理')).not.toBeInTheDocument(); + expect(screen.queryByText('system:cleanup')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(AdminTaskService.listTaskExecutions).toHaveBeenCalledWith( + expect.objectContaining({ + task_type: 'cloudflare:sync_member', + page: 1, + page_size: 50, + }), + ); + expect(AdminTaskService.listTaskExecutions).toHaveBeenCalledWith( + expect.objectContaining({ + task_type: 'cloudflare:sync_group', + page: 1, + page_size: 50, + }), + ); + }); + expect(AdminTaskService.listTaskExecutions).toHaveBeenCalledTimes(2); + }); + + it('shows empty state when no cloudflare tasks exist', async () => { + vi.mocked(AdminTaskService.listTaskExecutions).mockResolvedValue({ + items: [], + total: 0, + page: 1, + page_size: 50, + }); + + renderPanel(); + + expect(await screen.findByText('暂无同步任务')).toBeVisible(); + }); +}); diff --git a/frontend/tests/openflare/pages-service.test.ts b/frontend/tests/openflare/pages-service.test.ts new file mode 100644 index 00000000..10bc1db8 --- /dev/null +++ b/frontend/tests/openflare/pages-service.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AxiosResponse } from 'axios'; + +import apiClient from '@/lib/services/core/api-client'; +import { apiConfig } from '@/lib/services/core/config'; +import { PagesService } from '@/lib/services/openflare/pages.service'; + +vi.mock('@/lib/services/core/api-client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +function response(data: T) { + return { + data: { error_msg: '', data }, + status: 200, + statusText: 'OK', + headers: {}, + config: { headers: {} }, + } as AxiosResponse; +} + +describe('PagesService', () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + vi.mocked(apiClient.post).mockReset(); + }); + + it('requests deployment files using the backend deployment route', async () => { + vi.mocked(apiClient.get).mockResolvedValue(response([])); + + await PagesService.listDeploymentFiles(7); + + expect(apiClient.get).toHaveBeenCalledWith( + '/api/v1/d/pages/deployments/7/files', + expect.objectContaining({ params: undefined }), + ); + }); + + it('connects all source endpoints with explicit action payloads', async () => { + vi.mocked(apiClient.get).mockResolvedValue( + response({ source_type: 'manual' }), + ); + vi.mocked(apiClient.post).mockResolvedValue( + response({ source_type: 'manual' }), + ); + + await PagesService.getSource(12); + await PagesService.updateSource(12, { + source_type: 'remote_url', + remote_url: 'https://example.com/site.zip?token=secret', + allow_insecure: false, + }); + await PagesService.deleteSource(12); + await PagesService.checkSource(12); + await PagesService.syncSource(12); + + expect(apiClient.get).toHaveBeenCalledWith( + '/api/v1/d/pages/12/source', + expect.objectContaining({ params: undefined }), + ); + expect(apiClient.post).toHaveBeenNthCalledWith( + 1, + '/api/v1/d/pages/12/source/update', + expect.objectContaining({ + source_type: 'remote_url', + }), + undefined, + ); + expect(apiClient.post).toHaveBeenNthCalledWith( + 2, + '/api/v1/d/pages/12/source/delete', + undefined, + undefined, + ); + expect(apiClient.post).toHaveBeenNthCalledWith( + 3, + '/api/v1/d/pages/12/source/check', + {}, + undefined, + ); + expect(apiClient.post).toHaveBeenNthCalledWith( + 4, + '/api/v1/d/pages/12/source/sync', + {}, + undefined, + ); + }); + + it('sends the complete GitHub latest discriminator payload', async () => { + vi.mocked(apiClient.post).mockResolvedValue( + response({ + source: { source_type: 'manual' }, + check_task: null, + warning: '', + }), + ); + + await PagesService.updateSource(12, { + source_type: 'github_release', + repository_url: 'https://github.com/openflare/site', + release_selector: 'latest', + release_tag: '', + asset_name: 'dist.zip', + auto_update_enabled: false, + check_interval_minutes: 1440, + }); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/pages/12/source/update', + { + source_type: 'github_release', + repository_url: 'https://github.com/openflare/site', + release_selector: 'latest', + release_tag: '', + asset_name: 'dist.zip', + auto_update_enabled: false, + check_interval_minutes: 1440, + }, + undefined, + ); + }); + + it('sends safe disabled defaults with the GitHub tag discriminator', async () => { + vi.mocked(apiClient.post).mockResolvedValue( + response({ + source: { source_type: 'manual' }, + check_task: null, + warning: '', + }), + ); + + await PagesService.updateSource(12, { + source_type: 'github_release', + repository_url: 'https://github.com/openflare/site', + release_selector: 'tag', + release_tag: 'v1.2.3', + asset_name: 'site.tar.gz', + auto_update_enabled: false, + check_interval_minutes: 0, + }); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/pages/12/source/update', + { + source_type: 'github_release', + repository_url: 'https://github.com/openflare/site', + release_selector: 'tag', + release_tag: 'v1.2.3', + asset_name: 'site.tar.gz', + auto_update_enabled: false, + check_interval_minutes: 0, + }, + undefined, + ); + }); + + it('uploads only the package multipart field', async () => { + vi.mocked(apiClient.post).mockResolvedValue(response({})); + const file = new File(['site'], 'site.zip', { + type: 'application/zip', + }); + + await PagesService.uploadDeployment(8, { file }); + + const formData = vi.mocked(apiClient.post).mock.calls[0]?.[1]; + expect(formData).toBeInstanceOf(FormData); + expect(Array.from((formData as FormData).keys())).toEqual(['package']); + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/pages/8/deployments/upload', + formData, + expect.objectContaining({ timeout: apiConfig.uploadTimeout }), + ); + }); + + it('keeps the compatibility URL import on the long upload timeout', async () => { + vi.mocked(apiClient.post).mockResolvedValue(response({})); + + await PagesService.uploadDeploymentFromURL(8, { + url: 'https://example.com/site.zip', + }); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/pages/8/deployments/upload-from-url', + { url: 'https://example.com/site.zip' }, + expect.objectContaining({ timeout: apiConfig.uploadTimeout }), + ); + }); +}); diff --git a/frontend/tests/openflare/pages-source-auto-update.test.tsx b/frontend/tests/openflare/pages-source-auto-update.test.tsx new file mode 100644 index 00000000..84270f84 --- /dev/null +++ b/frontend/tests/openflare/pages-source-auto-update.test.tsx @@ -0,0 +1,369 @@ +import { act } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history'; +import { + getLatestSourceIdlePollingDecision, + PagesSourceCard, +} from '@/app/(main)/pages/detail/components/pages-source-card'; +import { + deploymentsQueryKey, + projectQueryKey, + projectsQueryKey, + sourceQueryKey, +} from '@/app/(main)/pages/components/pages-utils'; +import { + type PagesDeployment, + type PagesGitHubReleaseSource, + PagesService, +} from '@/lib/services/openflare'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + PagesService: { + getSource: vi.fn(), + updateSource: vi.fn(), + deleteSource: vi.fn(), + checkSource: vi.fn(), + syncSource: vi.fn(), + listDeployments: vi.fn(), + listDeploymentFiles: vi.fn(), + activateDeployment: vi.fn(), + deleteDeployment: vi.fn(), + }, + }; +}); + +function renderWithQuery(ui: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + const rendered = render( + + {ui} + , + ); + return { + ...rendered, + queryClient, + rerenderWithQuery: (nextUI: React.ReactNode) => + rendered.rerender( + + + {nextUI} + + , + ), + }; +} + +const latestSource: PagesGitHubReleaseSource = { + source_type: 'github_release', + github_repository: 'openflare/site', + release_selector: 'latest', + asset_name: 'dist.zip', + auto_update_enabled: true, + check_interval_minutes: 120, + sync_status: 'idle', + update_available: false, + last_seen: { + revision: 'b'.repeat(64), + label: 'v1.2.3', + asset_name: 'dist.zip', + }, + last_applied: { + revision: 'a'.repeat(64), + label: 'v1.2.2', + asset_name: 'dist.zip', + }, + last_checked_at: '2026-07-19T10:00:00Z', + last_synced_at: '2026-07-19T09:00:00Z', + next_check_at: '2026-07-19T12:00:00Z', + last_error: '', +}; + +describe('Pages latest source automatic updates', () => { + beforeEach(() => { + vi.mocked(PagesService.getSource).mockReset(); + vi.mocked(PagesService.updateSource).mockReset(); + vi.mocked(PagesService.deleteSource).mockReset(); + vi.mocked(PagesService.checkSource).mockReset(); + vi.mocked(PagesService.syncSource).mockReset(); + vi.mocked(PagesService.listDeployments).mockReset(); + vi.mocked(PagesService.listDeploymentFiles).mockReset(); + vi.mocked(PagesService.activateDeployment).mockReset(); + vi.mocked(PagesService.deleteDeployment).mockReset(); + }); + + it('backfills latest settings and hides them after selecting a fixed tag', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked(); + expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120); + + await user.click(screen.getByRole('radio', { name: '固定 Tag' })); + expect( + screen.queryByRole('switch', { name: '自动更新' }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument(); + }); + + it('does not show automatic schedule details for a fixed tag source', async () => { + vi.mocked(PagesService.getSource).mockResolvedValue({ + ...latestSource, + release_selector: 'tag', + release_tag: 'v1.2.3', + auto_update_enabled: false, + check_interval_minutes: 0, + next_check_at: null, + }); + + renderWithQuery(); + + expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible(); + expect(screen.queryByText('自动更新')).not.toBeInTheDocument(); + expect(screen.queryByText('下次检查')).not.toBeInTheDocument(); + expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument(); + }); + + it('rejects a latest interval outside 5–1440 minutes', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + const intervalInput = screen.getByLabelText('检查间隔(分钟)'); + await user.clear(intervalInput); + await user.type(intervalInput, '4'); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + + expect(screen.getByRole('alert')).toHaveTextContent( + '检查间隔须为 5–1440 分钟的整数', + ); + expect(intervalInput).toHaveAttribute( + 'aria-describedby', + 'pages-github-check-interval-description pages-github-check-interval-error', + ); + expect(PagesService.updateSource).not.toHaveBeenCalled(); + }); + + it('keeps an unsaved source draft when idle polling updates runtime fields', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + const { queryClient } = renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + const assetInput = screen.getByLabelText('Release Asset 文件名'); + const intervalInput = screen.getByLabelText('检查间隔(分钟)'); + await user.clear(assetInput); + await user.type(assetInput, 'draft.zip'); + await user.clear(intervalInput); + await user.type(intervalInput, '30'); + await user.click(screen.getByRole('switch', { name: '自动更新' })); + + act(() => { + queryClient.setQueryData(sourceQueryKey(9), { + ...latestSource, + last_checked_at: '2026-07-19T10:30:00Z', + next_check_at: '2026-07-19T12:30:00Z', + }); + }); + + await waitFor(() => { + expect(assetInput).toHaveValue('draft.zip'); + expect(intervalInput).toHaveValue(30); + expect( + screen.getByRole('switch', { name: '自动更新' }), + ).not.toBeChecked(); + }); + }); + + it('remounts project-scoped source state when the route project changes', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockImplementation(async (projectId) => ({ + ...latestSource, + github_repository: + projectId === 9 ? 'openflare/project-nine' : 'openflare/project-ten', + })); + const { rerenderWithQuery } = renderWithQuery( + , + ); + + await user.click(await screen.findByRole('button', { name: '配置' })); + expect(screen.getByRole('dialog')).toBeVisible(); + + rerenderWithQuery(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(await screen.findByText('openflare/project-ten')).toBeVisible(); + }); + + it('uses low-frequency, near-due and bounded overdue polling', () => { + const now = Date.parse('2026-07-19T10:00:00Z'); + const farSource = { + ...latestSource, + next_check_at: new Date(now + 60 * 60 * 1_000).toISOString(), + }; + expect(getLatestSourceIdlePollingDecision(farSource, now, null)).toEqual({ + interval: 5 * 60 * 1_000, + overdueWindow: null, + }); + + const nearSource = { + ...latestSource, + next_check_at: new Date(now + 60_000).toISOString(), + }; + expect(getLatestSourceIdlePollingDecision(nearSource, now, null)).toEqual({ + interval: 30_000, + overdueWindow: null, + }); + + const overdueSource = { + ...latestSource, + next_check_at: new Date(now - 1).toISOString(), + }; + const overdue = getLatestSourceIdlePollingDecision( + overdueSource, + now, + null, + ); + expect(overdue.interval).toBe(30_000); + expect( + getLatestSourceIdlePollingDecision( + overdueSource, + now + 10 * 60 * 1_000, + overdue.overdueWindow, + ).interval, + ).toBe(false); + + expect( + getLatestSourceIdlePollingDecision( + { + ...latestSource, + release_selector: 'tag', + release_tag: 'v1.2.3', + auto_update_enabled: false, + check_interval_minutes: 0, + }, + now, + null, + ).interval, + ).toBe(false); + }); + + it('refreshes source consumers after a background deployment is applied', async () => { + vi.mocked(PagesService.getSource).mockResolvedValue(latestSource); + const { queryClient } = renderWithQuery(); + expect(await screen.findByText('openflare/site')).toBeVisible(); + + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const updatedSource: PagesGitHubReleaseSource = { + ...latestSource, + last_applied: { + revision: 'b'.repeat(64), + label: 'v1.2.3', + asset_name: 'dist.zip', + }, + last_synced_at: '2026-07-19T10:30:00Z', + }; + vi.mocked(PagesService.getSource).mockResolvedValue(updatedSource); + + act(() => { + queryClient.setQueryData(sourceQueryKey(9), updatedSource); + }); + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: sourceQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: deploymentsQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['openflare', 'pages', 'deployment-files', 9], + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectsQueryKey, + }); + }); + }); + + it('warns that rollback closes auto update and refreshes four query groups', async () => { + const user = userEvent.setup(); + const deployment: PagesDeployment = { + id: 51, + project_id: 9, + deployment_number: 7, + checksum: 'd'.repeat(64), + status: 'uploaded', + file_count: 12, + total_size: 4_096, + created_by: 'user:1', + source_type: 'github_release', + source_label: 'v1.2.2', + trigger_type: 'scheduled_auto_update', + created_at: '2026-07-19T10:00:00Z', + activated_at: null, + }; + vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]); + vi.mocked(PagesService.activateDeployment).mockResolvedValue({} as never); + + const { queryClient } = renderWithQuery( + , + ); + await screen.findByText('GitHub · v1.2.2 · 定时更新'); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + + await user.click(screen.getByRole('button', { name: '激活' })); + const dialog = screen.getByRole('alertdialog'); + expect( + within(dialog).getByText( + '激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。', + ), + ).toBeVisible(); + await user.click(within(dialog).getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(PagesService.activateDeployment).toHaveBeenCalledWith(9, 51); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: deploymentsQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: sourceQueryKey(9), + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: projectsQueryKey, + }); + }); + }); +}); diff --git a/frontend/tests/openflare/pages-source-ui.test.tsx b/frontend/tests/openflare/pages-source-ui.test.tsx new file mode 100644 index 00000000..74c03c67 --- /dev/null +++ b/frontend/tests/openflare/pages-source-ui.test.tsx @@ -0,0 +1,647 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog'; +import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history'; +import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card'; +import { + AdminTaskService, + type TaskExecution, + type TaskExecutionStatus, +} from '@/lib/services/admin'; +import { + type PagesDeployment, + type PagesGitHubReleaseSource, + type PagesRemoteURLSource, + PagesService, +} from '@/lib/services/openflare'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + PagesService: { + getSource: vi.fn(), + updateSource: vi.fn(), + deleteSource: vi.fn(), + checkSource: vi.fn(), + syncSource: vi.fn(), + listDeployments: vi.fn(), + listDeploymentFiles: vi.fn(), + activateDeployment: vi.fn(), + deleteDeployment: vi.fn(), + uploadDeployment: vi.fn(), + }, + }; +}); + +vi.mock('@/lib/services/admin', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AdminTaskService: { + getTaskExecution: vi.fn(), + }, + }; +}); + +function renderWithQuery(ui: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + return render( + + {ui} + , + ); +} + +const remoteSource: PagesRemoteURLSource = { + source_type: 'remote_url', + remote_url: 'https://artifacts.example.com/site.zip?token=secret', + allow_insecure: false, + sync_status: 'idle', + last_applied: { + revision: 'a'.repeat(64), + label: 'site.zip', + }, + last_synced_at: '2026-07-19T10:00:00Z', + last_error: '', +}; + +const githubLatestSource: PagesGitHubReleaseSource = { + source_type: 'github_release', + github_repository: 'openflare/site', + release_selector: 'latest', + asset_name: 'dist.zip', + auto_update_enabled: false, + check_interval_minutes: 1440, + sync_status: 'update_available', + update_available: true, + last_seen: { + revision: 'b'.repeat(64), + label: 'v1.2.3', + asset_name: 'dist.zip', + }, + last_applied: { + revision: 'a'.repeat(64), + label: 'v1.2.2', + asset_name: 'dist.zip', + }, + last_checked_at: '2026-07-19T10:00:00Z', + last_synced_at: '2026-07-18T10:00:00Z', + next_check_at: '2026-07-19T11:00:00Z', + last_error: '', +}; + +const githubAttentionRevision = 'c'.repeat(64); +const githubAttentionSource: PagesGitHubReleaseSource = { + ...githubLatestSource, + sync_status: 'attention', + update_available: true, + last_seen: { + revision: githubAttentionRevision, + label: 'v1.2.3', + asset_name: 'dist.zip', + }, +}; + +function taskExecution( + status: TaskExecutionStatus, + errorMessage = '', +): TaskExecution { + return { + id: '42', + task_id: 'manual_of_pages_source_action_1', + task_type: 'of_pages_source_action', + task_name: 'Pages 来源动作', + status, + retryable: false, + max_retry: 0, + retry_count: 0, + log: '', + error_message: errorMessage, + result: '', + duration: 1, + payload: '', + triggered_by: 'admin:1', + created_at: '2026-07-19T10:00:00Z', + updated_at: '2026-07-19T10:00:01Z', + }; +} + +describe('Pages source UI', () => { + beforeEach(() => { + vi.mocked(PagesService.getSource).mockReset(); + vi.mocked(PagesService.updateSource).mockReset(); + vi.mocked(PagesService.deleteSource).mockReset(); + vi.mocked(PagesService.checkSource).mockReset(); + vi.mocked(PagesService.syncSource).mockReset(); + vi.mocked(PagesService.listDeployments).mockReset(); + vi.mocked(PagesService.listDeploymentFiles).mockReset(); + vi.mocked(PagesService.activateDeployment).mockReset(); + vi.mocked(PagesService.deleteDeployment).mockReset(); + vi.mocked(PagesService.uploadDeployment).mockReset(); + vi.mocked(AdminTaskService.getTaskExecution).mockReset(); + }); + + it('offers the three source types without future repository build controls', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue({ + source_type: 'manual', + }); + + renderWithQuery(); + + expect(await screen.findByText('本地部署包')).toBeVisible(); + expect(screen.getByRole('button', { name: '配置' })).toBeVisible(); + expect(screen.queryByText('检查更新')).not.toBeInTheDocument(); + expect(screen.queryByText('自动更新')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '配置' })); + expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible(); + expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible(); + expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible(); + expect(screen.getByRole('radio', { name: '手动部署' })).toBeChecked(); + expect(screen.queryByText('构建命令')).not.toBeInTheDocument(); + expect(screen.queryByText('输出目录')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('radio', { name: 'GitHub Release' })); + expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked(); + expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(1440); + }); + + it('submits the GitHub latest automatic update settings', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue({ + source_type: 'manual', + }); + vi.mocked(PagesService.updateSource).mockResolvedValue({ + source: githubLatestSource, + check_task: null, + warning: '', + }); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + await user.click(screen.getByRole('radio', { name: 'GitHub Release' })); + await user.type( + screen.getByLabelText('GitHub 仓库 URL'), + 'https://github.com/openflare/site', + ); + expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue( + 'dist.zip', + ); + await user.click(screen.getByRole('switch', { name: '自动更新' })); + const intervalInput = screen.getByLabelText('检查间隔(分钟)'); + await user.clear(intervalInput); + await user.type(intervalInput, '15'); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + + await waitFor(() => { + expect(PagesService.updateSource).toHaveBeenCalledWith(9, { + source_type: 'github_release', + repository_url: 'https://github.com/openflare/site', + release_selector: 'latest', + release_tag: '', + asset_name: 'dist.zip', + auto_update_enabled: true, + check_interval_minutes: 15, + }); + }); + }); + + it('rejects non-canonical GitHub repository URL paths', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue({ + source_type: 'manual', + }); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + await user.click(screen.getByRole('radio', { name: 'GitHub Release' })); + const repositoryInput = screen.getByLabelText('GitHub 仓库 URL'); + await user.type(repositoryInput, 'https://github.com//openflare/site'); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + expect( + screen.getByText( + '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址', + ), + ).toBeVisible(); + + await user.clear(repositoryInput); + await user.type(repositoryInput, 'https://github.com/openflare/site/'); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + expect(PagesService.updateSource).not.toHaveBeenCalled(); + }); + + it('submits the GitHub tag discriminator with safe disabled defaults', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue({ + source_type: 'manual', + }); + vi.mocked(PagesService.updateSource).mockResolvedValue({ + source: { + ...githubLatestSource, + release_selector: 'tag', + release_tag: 'v1.2.3', + auto_update_enabled: false, + check_interval_minutes: 0, + next_check_at: null, + }, + check_task: null, + warning: '', + }); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + await user.click(screen.getByRole('radio', { name: 'GitHub Release' })); + await user.type( + screen.getByLabelText('GitHub 仓库 URL'), + 'https://github.com/openflare/site', + ); + await user.click(screen.getByRole('radio', { name: '固定 Tag' })); + expect( + screen.queryByRole('switch', { name: '自动更新' }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument(); + await user.type( + screen.getByLabelText('Release tag'), + 'release/candidate#1&channel=stable', + ); + await user.clear(screen.getByLabelText('Release Asset 文件名')); + await user.type( + screen.getByLabelText('Release Asset 文件名'), + ' site?arch=amd64#stable.zip ', + ); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + + await waitFor(() => { + expect(PagesService.updateSource).toHaveBeenCalledWith(9, { + source_type: 'github_release', + repository_url: 'https://github.com/openflare/site', + release_selector: 'tag', + release_tag: 'release/candidate#1&channel=stable', + asset_name: ' site?arch=amd64#stable.zip ', + auto_update_enabled: false, + check_interval_minutes: 0, + }); + }); + }); + + it('rejects unsafe GitHub tag and asset values before saving', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue({ + source_type: 'manual', + }); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + await user.click(screen.getByRole('radio', { name: 'GitHub Release' })); + await user.type( + screen.getByLabelText('GitHub 仓库 URL'), + 'https://github.com/openflare/site', + ); + await user.click(screen.getByRole('radio', { name: '固定 Tag' })); + await user.type(screen.getByLabelText('Release tag'), 'release//candidate'); + await user.clear(screen.getByLabelText('Release Asset 文件名')); + await user.type( + screen.getByLabelText('Release Asset 文件名'), + '../dist.zip', + ); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + + expect( + screen.getByText( + 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)', + ), + ).toBeVisible(); + expect( + screen.getByText( + 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符', + ), + ).toBeVisible(); + expect(PagesService.updateSource).not.toHaveBeenCalled(); + }); + + it('polls the initial check receipt and represents the queued window locally', async () => { + const user = userEvent.setup(); + const idleGitHubSource: PagesGitHubReleaseSource = { + ...githubLatestSource, + sync_status: 'idle', + update_available: false, + }; + vi.mocked(PagesService.getSource) + .mockResolvedValueOnce({ source_type: 'manual' }) + .mockResolvedValue(idleGitHubSource); + vi.mocked(PagesService.updateSource).mockResolvedValue({ + source: idleGitHubSource, + check_task: { + task_id: 'manual_of_pages_source_action_1', + execution_id: '42', + action: 'check', + }, + warning: '', + }); + vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue( + taskExecution('pending'), + ); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + await user.click(screen.getByRole('radio', { name: 'GitHub Release' })); + await user.type( + screen.getByLabelText('GitHub 仓库 URL'), + 'https://github.com/openflare/site', + ); + await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' })); + + await waitFor(() => { + expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42'); + expect(screen.getByText('检查中')).toBeVisible(); + }); + const checkButton = await screen.findByRole('button', { + name: /检查更新/, + }); + expect(checkButton).toBeDisabled(); + expect(within(checkButton).getByRole('status')).toBeVisible(); + expect(PagesService.checkSource).not.toHaveBeenCalled(); + expect(PagesService.syncSource).not.toHaveBeenCalled(); + }); + + it('shows GitHub latest automatic update state and schedule', async () => { + vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource); + + renderWithQuery(); + + expect(await screen.findByText('openflare/site')).toBeVisible(); + expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible(); + expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible(); + expect(screen.getByText('有可用更新')).toBeVisible(); + expect(screen.getByText('下次检查')).toBeVisible(); + expect(screen.getByText('自动更新')).toBeVisible(); + expect(screen.getByText('已关闭')).toBeVisible(); + expect(screen.getByText('检查间隔(分钟)')).toBeVisible(); + expect(screen.getByText('1440 分钟')).toBeVisible(); + }); + + it('dispatches a GitHub check and starts TaskExecution polling', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource); + vi.mocked(PagesService.checkSource).mockResolvedValue({ + task_id: 'manual_of_pages_source_action_1', + execution_id: '42', + action: 'check', + }); + vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue( + taskExecution('succeeded'), + ); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '检查更新' })); + + await waitFor(() => { + expect(PagesService.checkSource).toHaveBeenCalledWith(9); + expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42'); + expect(PagesService.syncSource).not.toHaveBeenCalled(); + }); + }); + + it('renders a GitHub check dispatch error', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource); + vi.mocked(PagesService.checkSource).mockRejectedValue( + new Error('GitHub API 暂不可用'), + ); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '检查更新' })); + + expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible(); + }); + + it('renders a TaskExecution polling error with an explicit retry', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource); + vi.mocked(PagesService.checkSource).mockResolvedValue({ + task_id: 'manual_of_pages_source_action_1', + execution_id: '42', + action: 'check', + }); + vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue( + new Error('任务状态暂时不可读取'), + ); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '检查更新' })); + + expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible(); + expect(screen.getByRole('button', { name: '重试' })).toBeVisible(); + }); + + it('requires the exact currently displayed revision for attention sync', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource); + vi.mocked(PagesService.syncSource).mockResolvedValue({ + task_id: 'manual_of_pages_source_action_1', + execution_id: '42', + action: 'sync', + }); + vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue( + taskExecution('succeeded'), + ); + + renderWithQuery(); + + const alert = await screen.findByRole('alert'); + expect( + within(alert).getByText('Release Asset 发生变化,需要显式确认'), + ).toBeVisible(); + expect(within(alert).getByText(githubAttentionRevision)).toBeVisible(); + + await user.click(screen.getByRole('button', { name: '同步并发布' })); + const dialog = screen.getByRole('alertdialog'); + expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible(); + await user.click( + within(dialog).getByRole('button', { name: '确认并发布' }), + ); + + await waitFor(() => { + expect(PagesService.syncSource).toHaveBeenCalledWith(9, { + confirmed_revision: githubAttentionRevision, + }); + }); + }); + + it('edits the remote URL as plain text', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource); + vi.mocked(PagesService.updateSource).mockResolvedValue({ + source: { + ...remoteSource, + remote_url: 'https://new.example.com/site.zip?token=new', + }, + check_task: null, + warning: '', + }); + + renderWithQuery(); + + expect( + await screen.findByText( + 'https://artifacts.example.com/site.zip?token=secret', + ), + ).toBeVisible(); + + await user.click(screen.getByRole('button', { name: '配置' })); + const input = screen.getByLabelText('Remote URL'); + expect(input).toHaveAttribute('type', 'url'); + expect(input).toHaveValue( + 'https://artifacts.example.com/site.zip?token=secret', + ); + await user.clear(input); + await user.type(input, 'https://new.example.com/site.zip?token=new'); + await user.click(screen.getByRole('button', { name: '保存 Remote 来源' })); + + await waitFor(() => { + expect(PagesService.updateSource).toHaveBeenCalledWith(9, { + source_type: 'remote_url', + remote_url: 'https://new.example.com/site.zip?token=new', + allow_insecure: false, + }); + }); + }); + + it('saves the allow-insecure connection switch for remote sources', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource); + vi.mocked(PagesService.updateSource).mockResolvedValue({ + source: { ...remoteSource, allow_insecure: true }, + check_task: null, + warning: '', + }); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '配置' })); + expect( + screen.getByRole('switch', { name: '允许不安全的连接' }), + ).not.toBeChecked(); + await user.click(screen.getByRole('switch', { name: '允许不安全的连接' })); + await user.click(screen.getByRole('button', { name: '保存 Remote 来源' })); + + await waitFor(() => { + expect(PagesService.updateSource).toHaveBeenCalledWith(9, { + source_type: 'remote_url', + remote_url: 'https://artifacts.example.com/site.zip?token=secret', + allow_insecure: true, + }); + }); + }); + + it('polls the existing task execution detail after sync dispatch', async () => { + const user = userEvent.setup(); + vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource); + vi.mocked(PagesService.syncSource).mockResolvedValue({ + task_id: 'manual_of_pages_source_action_1', + execution_id: '42', + action: 'sync', + }); + vi.mocked(AdminTaskService.getTaskExecution) + .mockResolvedValueOnce(taskExecution('pending')) + .mockResolvedValue(taskExecution('succeeded')); + + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: '同步并发布' })); + + await waitFor(() => { + expect(PagesService.syncSource).toHaveBeenCalledWith(9, {}); + expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42'); + }); + expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled(); + + await waitFor( + () => { + expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2); + }, + { timeout: 3_500 }, + ); + }); + + it('shows the actual project entry and no one-off URL upload tab', () => { + renderWithQuery( + , + ); + + expect(screen.getByText('dist/site/home.html')).toBeVisible(); + expect(screen.queryByText('从 URL 下载')).not.toBeInTheDocument(); + expect(screen.queryByText('部署包下载链接')).not.toBeInTheDocument(); + }); + + it('renders a deployment query failure instead of an empty history', async () => { + vi.mocked(PagesService.listDeployments).mockRejectedValue( + new Error('部署历史暂时不可用'), + ); + + renderWithQuery(); + + expect(await screen.findByText('部署历史暂时不可用')).toBeVisible(); + expect(screen.queryByText('暂无部署')).not.toBeInTheDocument(); + }); + + it('renders the immutable GitHub deployment provenance in Chinese', async () => { + const deployment: PagesDeployment = { + id: 51, + project_id: 9, + deployment_number: 7, + checksum: 'd'.repeat(64), + status: 'active', + file_count: 12, + total_size: 4_096, + created_by: 'user:1', + source_type: 'github_release', + source_label: 'v1.2.3', + trigger_type: 'manual_sync', + created_at: '2026-07-19T10:00:00Z', + activated_at: '2026-07-19T10:00:01Z', + }; + vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]); + + renderWithQuery( + , + ); + + expect( + await screen.findAllByText('GitHub · v1.2.3 · 手动同步'), + ).toHaveLength(2); + expect(screen.getByText('Production')).toBeVisible(); + expect(screen.getByText('All deployments')).toBeVisible(); + }); +}); diff --git a/frontend/tests/openflare/pages-source-validation.test.ts b/frontend/tests/openflare/pages-source-validation.test.ts new file mode 100644 index 00000000..e19d3ece --- /dev/null +++ b/frontend/tests/openflare/pages-source-validation.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { + validGitHubAssetName, + validGitHubReleaseTag, + validGitHubRepositoryURL, +} from '@/app/(main)/pages/detail/components/pages-source-validation'; + +describe('Pages GitHub source validation', () => { + it('accepts only canonical public GitHub repository URLs', () => { + expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true); + expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe( + true, + ); + + const invalid = [ + 'http://github.com/acme/site', + 'https://github.com//acme/site', + 'https://github.com/acme/site/', + 'https://github.com/acme/site/extra', + 'https://github.com/acme/./site', + 'https://github.com/acme/../site', + 'https://github.com/acme/%73ite', + 'https://github.com/acme/site?token=secret', + 'https://github.com:443/acme/site', + String.raw`https://github.com/acme\site`, + ]; + for (const value of invalid) { + expect(validGitHubRepositoryURL(value), value).toBe(false); + } + }); + + it('mirrors Git ref rules while preserving legal release tag characters', () => { + const valid = [ + '@', + 'release/v1#stable&channel=prod', + 'foo.LOCK', + '中文/发布=稳定', + ]; + for (const value of valid) { + expect(validGitHubReleaseTag(value), value).toBe(true); + } + + const invalid = [ + '', + 'release v1', + 'release~v1', + 'release^v1', + 'release:v1', + 'release?v1', + 'release*v1', + 'release[v1', + String.raw`release\v1`, + 'release..v1', + 'release@{v1', + 'release//v1', + '/release', + 'release/', + 'release.', + '.release', + 'release/.candidate', + 'release/v1.lock', + 'release\nsecret', + 'release\u2028secret', + 'release\u202esecret', + 'a'.repeat(256), + ]; + for (const value of invalid) { + expect(validGitHubReleaseTag(value), value).toBe(false); + } + }); + + it('preserves exact legal asset names and rejects path or display controls', () => { + const valid = [ + 'dist.zip', + 'dist?channel=stable&part#1.zip', + ' dist.zip ', + 'build=production.zip', + ]; + for (const value of valid) { + expect(validGitHubAssetName(value), value).toBe(true); + } + + const invalid = [ + '', + '.', + '..', + '../dist.zip', + String.raw`dir\dist.zip`, + 'dist\n.zip', + 'dist\u2028.zip', + 'dist\u202e.zip', + 'a'.repeat(256), + '\ud800', + ]; + for (const value of invalid) { + expect(validGitHubAssetName(value), value).toBe(false); + } + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 00000000..a7425f4a --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,12 @@ +import '@testing-library/jest-dom/vitest'; + +// input-otp(登录 OTP 组件)依赖 ResizeObserver,jsdom 未内置 +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; +} diff --git a/frontend/tests/unit/access-log-overview-params.test.ts b/frontend/tests/unit/access-log-overview-params.test.ts new file mode 100644 index 00000000..2890080e --- /dev/null +++ b/frontend/tests/unit/access-log-overview-params.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { serializeSearchParams } from '@/lib/services/core/search-params'; + +describe('serializeSearchParams', () => { + it('serializes hosts arrays as repeated keys for Gin QueryArray', () => { + const query = serializeSearchParams({ + hours: 168, + hosts: ['gist.arctel.de'], + }); + expect(query).toBe('hours=168&hosts=gist.arctel.de'); + }); + + it('serializes multiple hosts without bracket notation', () => { + const query = serializeSearchParams({ + hosts: ['a.example', 'b.example'], + }); + expect(query).toBe('hosts=a.example&hosts=b.example'); + expect(query).not.toContain('hosts[]'); + expect(query).not.toContain('hosts%5B%5D'); + }); +}); diff --git a/frontend/tests/unit/waf-binding-order.test.tsx b/frontend/tests/unit/waf-binding-order.test.tsx new file mode 100644 index 00000000..de2d03ce --- /dev/null +++ b/frontend/tests/unit/waf-binding-order.test.tsx @@ -0,0 +1,130 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + reorderRuleIDs, + WafSection, +} from '@/app/(main)/proxy-routes/detail/components/waf-section'; +import type { ProxyRouteItem, WAFRule } from '@/lib/services/openflare'; +import { WafService } from '@/lib/services/openflare'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + WafService: { + listSiteRuleGroups: vi.fn(), + updateSiteRuleGroups: vi.fn(), + }, + }; +}); + +const graph = { + schema_version: 1, + nodes: [ + { + id: 'start', + type: 'start' as const, + position: { x: 0, y: 0 }, + config: {}, + }, + { + id: 'allow', + type: 'allow' as const, + position: { x: 100, y: 0 }, + config: {}, + }, + ], + edges: [ + { id: 'edge', source: 'start', source_handle: 'next', target: 'allow' }, + ], +}; + +function rule(id: number, name: string, isGlobal = false): WAFRule { + return { + id, + name, + enabled: true, + is_global: isGlobal, + graph, + revision: 1, + applied_site_ids: [], + applied_site_count: 0, + created_at: '', + updated_at: '', + }; +} + +describe('WAF route binding order', () => { + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.mocked(WafService.listSiteRuleGroups).mockReset(); + vi.mocked(WafService.updateSiteRuleGroups).mockReset(); + vi.mocked(WafService.listSiteRuleGroups).mockResolvedValue({ + route_id: 9, + global_rule_group: rule(99, '全局规则', true), + rule_groups: [rule(1, '规则 A'), rule(2, '规则 B'), rule(3, '规则 C')], + applied_rule_groups: [rule(1, '规则 A'), rule(2, '规则 B')], + applied_ids: [1, 2], + }); + vi.mocked(WafService.updateSiteRuleGroups).mockImplementation( + async (_routeID, ids) => ({ + route_id: 9, + global_rule_group: rule(99, '全局规则', true), + rule_groups: [rule(1, '规则 A'), rule(2, '规则 B'), rule(3, '规则 C')], + applied_rule_groups: ids.map((id) => rule(id, `规则 ${id}`)), + applied_ids: ids, + }), + ); + }); + + it('reorders by the active and target IDs reported by drag end', () => { + expect(reorderRuleIDs([1, 2, 3], 3, 1)).toEqual([3, 1, 2]); + }); + + it('keeps the global rule fixed and submits custom rules in UI order', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + + + , + ); + + expect(await screen.findByText('全局规则')).toBeInTheDocument(); + expect(screen.getByText('始终生效')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: '上移全局规则' }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: '选择规则 C' })); + fireEvent.click(screen.getByRole('button', { name: '上移规则 C' })); + fireEvent.click(screen.getByRole('button', { name: '上移规则 C' })); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + expect(WafService.updateSiteRuleGroups).toHaveBeenCalledWith( + 9, + [3, 1, 2], + ); + }); + }); +}); diff --git a/frontend/tests/unit/waf-rule-service.test.tsx b/frontend/tests/unit/waf-rule-service.test.tsx new file mode 100644 index 00000000..bfafc1a9 --- /dev/null +++ b/frontend/tests/unit/waf-rule-service.test.tsx @@ -0,0 +1,183 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; +import type { AxiosResponse } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import WafPage from '@/app/(main)/waf/page'; +import apiClient from '@/lib/services/core/api-client'; +import { WafService } from '@/lib/services/openflare'; +import type { WAFSiteRuleGroups } from '@/lib/services/openflare'; +import { WafService as DirectWafService } from '@/lib/services/openflare/waf.service'; + +const pushMock = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock }), +})); + +vi.mock('@/lib/services/core/api-client', () => ({ + default: { + post: vi.fn(), + }, +})); + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + WafService: { + ...actual.WafService, + listRuleGroups: vi.fn(), + listIPGroups: vi.fn(), + createRule: vi.fn(), + deleteRuleGroup: vi.fn(), + }, + }; +}); + +const graph = { + schema_version: 1, + nodes: [ + { + id: 'start', + type: 'start' as const, + position: { x: 0, y: 0 }, + config: {}, + }, + { + id: 'allow', + type: 'allow' as const, + position: { x: 200, y: 0 }, + config: {}, + }, + ], + edges: [ + { id: 'edge', source: 'start', source_handle: 'next', target: 'allow' }, + ], +}; + +const ruleSummary = { + id: 17, + name: '入口防护', + enabled: false, + is_global: false, + graph, + revision: 1, + applied_site_ids: [], + applied_site_count: 0, + created_at: '', + updated_at: '', +}; + +// The binding API returns graph-backed rule summaries, not obsolete fixed-chain fields. +const siteRuleGroupsFixture: WAFSiteRuleGroups = { + route_id: 9, + global_rule_group: null, + rule_groups: [ruleSummary], + applied_rule_groups: [ruleSummary], + applied_ids: [17], +}; + +describe('WafService rule graph API', () => { + beforeEach(() => { + vi.mocked(apiClient.post).mockReset(); + }); + + it('models site bindings with graph-backed rule summaries', () => { + expect(siteRuleGroupsFixture.rule_groups[0]?.graph.nodes).toHaveLength(2); + expect(siteRuleGroupsFixture.applied_ids).toEqual([17]); + }); + + it('creates a rule with a name-only payload', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { error_msg: '', data: { id: 17, name: '入口防护', graph } }, + } as AxiosResponse); + + await DirectWafService.createRule({ name: '入口防护' }); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/waf/rule-groups', + { name: '入口防护' }, + undefined, + ); + }); + + it('saves the graph together with its current revision', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { error_msg: '', data: { id: 17, revision: 4, graph } }, + } as AxiosResponse); + + await DirectWafService.saveRuleGraph(17, { revision: 3, graph }); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/waf/rule-groups/17/graph', + { revision: 3, graph }, + undefined, + ); + }); + + it('updates rule metadata with the current name and enabled state', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { error_msg: '', data: { ...ruleSummary, enabled: true } }, + } as AxiosResponse); + + await DirectWafService.updateRuleMeta(17, { + name: '入口防护', + enabled: true, + }); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/d/waf/rule-groups/17/meta', + { name: '入口防护', enabled: true }, + undefined, + ); + }); + + it('does not expose the retired rule-to-sites binding service', () => { + expect(DirectWafService).not.toHaveProperty('updateRuleGroupSites'); + }); +}); + +describe('WAF rule creation flow', () => { + beforeEach(() => { + pushMock.mockReset(); + vi.mocked(WafService.listRuleGroups).mockReset(); + vi.mocked(WafService.listRuleGroups).mockResolvedValue([]); + vi.mocked(WafService.listIPGroups).mockReset(); + vi.mocked(WafService.listIPGroups).mockResolvedValue([]); + vi.mocked(WafService.createRule).mockReset(); + vi.mocked(WafService.createRule).mockResolvedValue(ruleSummary); + }); + + it('creates by name and navigates directly to the graph editor', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + render( + + + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '新建规则' })); + expect(screen.queryByText('IP 白名单')).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: '规则名称' }), { + target: { value: '入口防护' }, + }); + fireEvent.click(screen.getByRole('button', { name: '创建并编排' })); + + await waitFor(() => { + expect(WafService.createRule).toHaveBeenCalledWith({ name: '入口防护' }); + expect(pushMock).toHaveBeenCalledWith('/waf/rules/editor?id=17'); + }); + }); +}); diff --git a/frontend/tests/zone/resolve-zone-domain-input.test.ts b/frontend/tests/zone/resolve-zone-domain-input.test.ts new file mode 100644 index 00000000..a5c6d147 --- /dev/null +++ b/frontend/tests/zone/resolve-zone-domain-input.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveZoneDomainInput } from '@/app/(main)/websites/components/resolve-zone-domain-input'; + +describe('resolveZoneDomainInput', () => { + it('maps short label and apex @ under zone root', () => { + expect(resolveZoneDomainInput('api', 'example.com')).toEqual({ + domain: 'api.example.com', + }); + expect(resolveZoneDomainInput('@', 'example.com')).toEqual({ + domain: 'example.com', + }); + }); + + it('accepts full FQDN under the zone', () => { + expect( + resolveZoneDomainInput('www.api.example.com', 'example.com'), + ).toEqual({ + domain: 'www.api.example.com', + }); + expect(resolveZoneDomainInput('example.com', 'example.com')).toEqual({ + domain: 'example.com', + }); + }); + + it('rejects foreign domains and wildcards', () => { + expect( + resolveZoneDomainInput('evil.com', 'example.com').error, + ).toBeTruthy(); + expect( + resolveZoneDomainInput('*.example.com', 'example.com').error, + ).toBeTruthy(); + }); +}); diff --git a/frontend/tests/zone/websites-page.test.tsx b/frontend/tests/zone/websites-page.test.tsx new file mode 100644 index 00000000..84363774 --- /dev/null +++ b/frontend/tests/zone/websites-page.test.tsx @@ -0,0 +1,82 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import WebsitesPage from '@/app/(main)/websites/page'; +import { ZoneService } from '@/lib/services/openflare'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, ZoneService: { list: vi.fn() } }; +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +describe('WebsitesPage', () => { + it('filters zones, shows domain counts, and links to stable ID routes', async () => { + vi.mocked(ZoneService.list).mockResolvedValue([ + { + id: 42, + domain: 'example.com', + domain_count: 3, + created_at: '', + updated_at: '', + }, + { + id: 43, + domain: 'another.com', + domain_count: 0, + created_at: '', + updated_at: '', + }, + ]); + + renderPage(); + + expect(await screen.findByText('example.com')).toBeVisible(); + expect(screen.getByText('3')).toBeVisible(); + expect(screen.getByText('0')).toBeVisible(); + expect(screen.getByRole('columnheader', { name: '根域' })).toBeVisible(); + + fireEvent.change(screen.getByPlaceholderText('搜索 Zone 根域'), { + target: { value: 'example' }, + }); + expect(screen.getByRole('link', { name: '管理' })).toHaveAttribute( + 'href', + '/websites/42', + ); + expect(screen.queryByText('another.com')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/zone/zone-domain-selector.test.tsx b/frontend/tests/zone/zone-domain-selector.test.tsx new file mode 100644 index 00000000..7fa082d0 --- /dev/null +++ b/frontend/tests/zone/zone-domain-selector.test.tsx @@ -0,0 +1,113 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { ZoneDomainSelector } from '@/app/(main)/proxy-routes/components/zone-domain-selector'; +import type { ZoneDomainItem, ZoneItem } from '@/lib/services/openflare'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +const zones: ZoneItem[] = [ + { id: 1, domain: 'example.com', created_at: '', updated_at: '' }, +]; + +const domains: ZoneDomainItem[] = [ + { + id: 7, + zone_id: 1, + proxy_route_id: null, + domain: 'api.example.com', + cert_id: 9, + created_at: '', + updated_at: '', + }, + { + id: 8, + zone_id: 1, + proxy_route_id: 99, + domain: 'bound.example.com', + cert_id: null, + created_at: '', + updated_at: '', + }, +]; + +function renderSelector(ui: React.ReactElement) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + {ui} + , + ); +} + +describe('ZoneDomainSelector', () => { + it('renders domains and toggles selection', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderSelector( + , + ); + + expect(screen.getByText('api.example.com')).toBeVisible(); + // Bound to another route — hidden by default + expect(screen.queryByText('bound.example.com')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /快捷新增域名/ })).toBeVisible(); + + await user.click(screen.getByText('api.example.com')); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('hides domains bound to another route', () => { + renderSelector( + , + ); + + expect(screen.getByText('api.example.com')).toBeVisible(); + expect(screen.queryByText('bound.example.com')).not.toBeInTheDocument(); + }); + + it('still shows domains already bound to the current route', () => { + renderSelector( + , + ); + + expect(screen.getByText('bound.example.com')).toBeVisible(); + expect(screen.getByText('api.example.com')).toBeVisible(); + }); +}); diff --git a/frontend/tests/zone/zone-page.test.tsx b/frontend/tests/zone/zone-page.test.tsx new file mode 100644 index 00000000..4ab64e28 --- /dev/null +++ b/frontend/tests/zone/zone-page.test.tsx @@ -0,0 +1,166 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ZonePageClient } from '@/app/(main)/websites/[zoneId]/page-client'; +import { + ProxyRouteService, + TlsCertificateService, + ZoneService, +} from '@/lib/services/openflare'; +import { NextIntlClientProvider } from 'next-intl'; +import zhCN from '@/messages/zh-CN.json'; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', ResizeObserverMock); + +let mockZoneId = '42'; +let mockParamZoneId = '42'; + +const replaceMock = vi.fn(); + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: replaceMock, back: vi.fn() }), + usePathname: () => `/websites/${mockZoneId}`, + useSearchParams: () => new URLSearchParams(), + useParams: () => ({ zoneId: mockParamZoneId }), +})); + +vi.mock('@/lib/services/openflare', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ZoneService: { + getOverview: vi.fn(), + getStats: vi.fn(), + deleteById: vi.fn(), + list: vi.fn(), + }, + TlsCertificateService: { + list: vi.fn(), + }, + ProxyRouteService: { + list: vi.fn(), + }, + }; +}); + +function renderPage(zoneId: number, paramZoneId = zoneId) { + mockZoneId = String(zoneId); + mockParamZoneId = String(paramZoneId); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }); + return render( + + + + + , + ); +} + +describe('ZonePageClient', () => { + beforeEach(() => { + mockParamZoneId = mockZoneId; + vi.mocked(ZoneService.getOverview).mockReset(); + vi.mocked(ZoneService.getStats).mockReset(); + vi.mocked(ZoneService.getStats).mockResolvedValue({ + range: '24h', + range_hours: 24, + window_started_at: new Date().toISOString(), + window_ended_at: new Date().toISOString(), + bucket_minutes: 60, + unique_visitors: 0, + request_count: 0, + bytes_sent: 0, + domain_count: 0, + available: true, + series: [], + }); + vi.mocked(ZoneService.deleteById).mockReset(); + vi.mocked(TlsCertificateService.list).mockReset(); + vi.mocked(TlsCertificateService.list).mockResolvedValue([]); + vi.mocked(ProxyRouteService.list).mockReset(); + vi.mocked(ProxyRouteService.list).mockResolvedValue([]); + }); + + it('loads the overview by stable ID and exposes all tabs including empty domains', async () => { + vi.mocked(ZoneService.getOverview).mockImplementation(async () => ({ + zone: { id: 42, domain: 'example.com', created_at: '', updated_at: '' }, + domains: [], + })); + + renderPage(42); + + await waitFor(() => { + expect(ZoneService.getOverview).toHaveBeenCalledWith(42); + }); + expect( + await screen.findByRole('heading', { name: 'example.com' }), + ).toBeVisible(); + expect(await screen.findByText('查询窗口独立访客')).toBeVisible(); + expect(screen.getByText('请求总数')).toBeVisible(); + expect(screen.getByText('已提供的数据总计')).toBeVisible(); + expect(screen.getByRole('tab', { name: '域名 (0)' })).toBeVisible(); + expect(screen.getByRole('tab', { name: '证书 (0)' })).toBeVisible(); + expect(screen.queryByRole('tab', { name: '路由' })).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '设置' })).toBeVisible(); + }); + + it('uses the browser pathname ID when serving a static-export fallback shell', async () => { + vi.mocked(ZoneService.getOverview).mockImplementation(async () => ({ + zone: { id: 42, domain: 'example.com', created_at: '', updated_at: '' }, + domains: [], + })); + + renderPage(42, 1); + + await waitFor(() => { + expect(ZoneService.getOverview).toHaveBeenCalledWith(42); + }); + expect(ZoneService.getOverview).not.toHaveBeenCalledWith(1); + }); + + it('renders a not-found state for a missing Zone', async () => { + vi.mocked(ZoneService.getOverview).mockRejectedValue( + new Error('Zone 不存在'), + ); + + renderPage(42); + + expect( + await screen.findByText('网站不存在,可能已被删除或 ID 无效。'), + ).toBeVisible(); + }); + + it('renders invalid ID empty state without calling the API', async () => { + renderPage(0); + expect( + await screen.findByText('无效的网站 ID,请从网站列表进入详情页。'), + ).toBeVisible(); + expect(ZoneService.getOverview).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..7a3754ba --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./tests/setup.ts'], + }, +}); diff --git a/scripts/agent-entrypoint.sh b/scripts/agent-entrypoint.sh new file mode 100755 index 00000000..8eca1168 --- /dev/null +++ b/scripts/agent-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +RUNTIME_USER="openflare" +AGENT_BIN="/usr/local/bin/openflare-agent" + +fix_runtime_ownership() { + for target in /data /etc/openflare; do + if [ -d "$target" ]; then + chown -R "${RUNTIME_USER}:${RUNTIME_USER}" "$target" 2>/dev/null || true + chmod -R u+rwX,g+rX "$target" 2>/dev/null || true + fi + done + # Allow the runtime user to write to /usr/local/bin to support container self-updating + chown "${RUNTIME_USER}:${RUNTIME_USER}" /usr/local/bin "${AGENT_BIN}" 2>/dev/null || true +} + +if [ "$(id -u)" -eq 0 ]; then + fix_runtime_ownership + exec su-exec "${RUNTIME_USER}" "${AGENT_BIN}" "$@" +fi + +exec "${AGENT_BIN}" "$@" \ No newline at end of file diff --git a/scripts/fetch-agent-geoip-mmdb.sh b/scripts/fetch-agent-geoip-mmdb.sh new file mode 100755 index 00000000..98a446d8 --- /dev/null +++ b/scripts/fetch-agent-geoip-mmdb.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Download MaxMind GeoLite2 Country/City databases for packaging (Docker image COPY), +# not for go:embed into the agent binary. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="${GEOIP_OUT_DIR:-${ROOT}/dist/geoip}" +COUNTRY_URL="${GEOIP_MMDB_URL:-https://github.com/FyraLabs/geolite2/releases/latest/download/GeoLite2-Country.mmdb}" +CITY_URL="${GEOIP_CITY_MMDB_URL:-https://github.com/FyraLabs/geolite2/releases/latest/download/GeoLite2-City.mmdb}" + +mkdir -p "${OUT_DIR}" + +download_one() { + local url="$1" + local dest="$2" + local name="$3" + if curl -fsSL -o "${dest}.tmp" "$url" && [ -s "${dest}.tmp" ]; then + mv "${dest}.tmp" "$dest" + echo "GeoIP ${name} downloaded: $dest ($(wc -c <"$dest" | tr -d ' ') bytes)" + return 0 + fi + rm -f "${dest}.tmp" + if [ -s "$dest" ]; then + echo "GeoIP ${name} download failed, keeping existing: $dest" >&2 + return 0 + fi + echo "GeoIP ${name} missing and download failed: $url" >&2 + return 1 +} + +download_one "$COUNTRY_URL" "${OUT_DIR}/GeoLite2-Country.mmdb" "Country" +download_one "$CITY_URL" "${OUT_DIR}/GeoLite2-City.mmdb" "City" diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh new file mode 100755 index 00000000..e565a492 --- /dev/null +++ b/scripts/install-agent.sh @@ -0,0 +1,576 @@ +#!/usr/bin/env bash +set -euo pipefail + +# OpenFlare Agent Installer +# Usage: +# curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \ +# --server-url http://your-server:3000 \ +# --discovery-token your-token + +INSTALL_DIR="/opt/openflare-agent" +REPO="Rain-kl/OpenFlare" +SERVER_URL="" +DISCOVERY_TOKEN="" +AGENT_TOKEN="" +CREATE_SERVICE="true" +SERVICE_NAME="openflare-agent" +OPENRESTY_PATH="" +INSTALL_METHOD="" + +CURRENT_DIR="$(pwd)" +LOG_FILE="${CURRENT_DIR}/install.log" + +# Translation text variables +TXT_DOCKER_RESTARTED="Docker 已启动/重启并运行中" +TXT_LOW_DOCKER_VERSION="Docker 版本较低,建议升级到 20.10 及以上版本" +TXT_INSTALL_DOCKER_CONFIRM="未检测到 Docker,是否现在安装?(y/n) [y]: " +TXT_DOCKER_INSTALL_ONLINE="开始在线安装 Docker..." +TXT_INSTALL_DOCKER_ONLINE="使用 opkg 在线安装 Docker..." +TXT_CHOOSE_LOWEST_LATENCY_SOURCE="选择延迟最低的镜像源:" +TXT_CHOOSE_LOWEST_LATENCY_DELAY="延迟为:" +TXT_TRY_NEXT_LINK="尝试使用链接:" +TXT_DOWNLOAD_DOCKER_SCRIPT="下载 Docker 安装脚本..." +TXT_DOWNLOAD_DOCKER_SCRIPT_SUCCESS="下载成功:" +TXT_SUCCESSFULLY_MESSAGE="开始执行安装..." +TXT_DOWNLOAD_FAIELD="下载失败:" +TXT_ALL_DOWNLOAD_ATTEMPTS_FAILED="所有 Docker 下载尝试均失败,请手动安装 Docker" +TXT_DOCKER_INSTALL_FAIL="Docker 安装失败" +TXT_DOCKER_INSTALL_SUCCESS="Docker 安装成功" +TXT_CANNOT_SELECT_SOURCE="无法选择合适的镜像源" +TXT_REGIONS_OTHER_THAN_CHINA="检测到非中国大陆地区,使用官方源安装 Docker..." +TXT_DOCKER_START_NOTICE="正在启动 Docker 服务..." +TXT_CANCEL_INSTALL_DOCKER="已取消 Docker 安装" +TXT_INVALID_YN_INPUT="无效的输入,请输入 y 或 n" + +log() { + echo -e "[$(date +'%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +configure_accelerator() { + log "配置 Docker 国内镜像加速源..." + local docker_config_folder="/etc/docker" + if [[ ! -d "$docker_config_folder" ]]; then + mkdir -p "$docker_config_folder" + fi + cat > /etc/docker/daemon.json </dev/null; then + systemctl daemon-reload + systemctl restart docker + else + service dockerd restart + fi +} + +function Install_Docker(){ + if which docker >/dev/null 2>&1; then + docker_version=$(docker --version | grep -oE '[0-9]+\.[0-9]+' | head -n 1) + major_version=${docker_version%%.*} + minor_version=${docker_version##*.} + local service_cmd="service dockerd start && service dockerd status" + if command -v systemctl &>/dev/null; then + service_cmd="systemctl start docker && systemctl status docker" + fi + + set +e + local service_status=$($service_cmd 2>&1) + set -e + + if [[ $service_status == *running* ]]; then + log "$TXT_DOCKER_RESTARTED" + else + if [[ $major_version -lt 20 ]]; then + log "$TXT_LOW_DOCKER_VERSION" + fi + + set +e + local country_code=$(curl -s --max-time 5 ipinfo.io/country) + set -e + if [[ "$country_code" == "CN" ]]; then + configure_accelerator + fi + fi + else + while true; do + read -p "$TXT_INSTALL_DOCKER_CONFIRM" install_docker_choice + install_docker_choice=${install_docker_choice:-y} + case "$install_docker_choice" in + [yY]) + log "$TXT_DOCKER_INSTALL_ONLINE" + + if command -v opkg &>/dev/null; then + log "$TXT_INSTALL_DOCKER_ONLINE" + opkg update + opkg install luci-i18n-dockerman-zh-cn + opkg install zoneinfo-asia + service system restart + set +e + local country_code=$(curl -s --max-time 5 ipinfo.io/country) + set -e + if [[ "$country_code" == "CN" ]]; then + configure_accelerator + fi + else + set +e + local country_code=$(curl -s --max-time 5 ipinfo.io/country) + set -e + if [[ "$country_code" == "CN" ]]; then + sources=( + "https://mirrors.aliyun.com/docker-ce" + "https://mirrors.tencent.com/docker-ce" + "https://mirrors.163.com/docker-ce" + "https://mirrors.cernet.edu.cn/docker-ce" + ) + + docker_install_scripts=( + "https://get.docker.com" + "https://testingcf.jsdelivr.net/gh/docker/docker-install@master/install.sh" + "https://cdn.jsdelivr.net/gh/docker/docker-install@master/install.sh" + "https://fastly.jsdelivr.net/gh/docker/docker-install@master/install.sh" + "https://gcore.jsdelivr.net/gh/docker/docker-install@master/install.sh" + "https://raw.githubusercontent.com/docker/docker-install/master/install.sh" + ) + + get_average_delay() { + local source=$1 + local total_delay=0 + local iterations=2 + local timeout=2 + + for ((i = 0; i < iterations; i++)); do + delay=$(curl -o /dev/null -s -m $timeout -w "%{time_total}\n" "$source") + if [ $? -ne 0 ]; then + delay=$timeout + fi + total_delay=$(awk "BEGIN {print $total_delay + $delay}") + done + + average_delay=$(awk "BEGIN {print $total_delay / $iterations}") + echo "$average_delay" + } + + min_delay=99999999 + selected_source="" + + for source in "${sources[@]}"; do + average_delay=$(get_average_delay "$source" &) + + if (( $(awk 'BEGIN { print '"$average_delay"' < '"$min_delay"' }') )); then + min_delay=$average_delay + selected_source=$source + fi + done + wait + + if [ -n "$selected_source" ]; then + log "$TXT_CHOOSE_LOWEST_LATENCY_SOURCE $selected_source,$TXT_CHOOSE_LOWEST_LATENCY_DELAY $min_delay" + export DOWNLOAD_URL="$selected_source" + + for alt_source in "${docker_install_scripts[@]}"; do + log "$TXT_TRY_NEXT_LINK $alt_source $TXT_DOWNLOAD_DOCKER_SCRIPT" + if curl -fsSL --retry 2 --retry-delay 3 --connect-timeout 5 --max-time 10 "$alt_source" -o get-docker.sh; then + log "$TXT_DOWNLOAD_DOCKER_SCRIPT_SUCCESS $alt_source $TXT_SUCCESSFULLY_MESSAGE" + break + else + log "$TXT_DOWNLOAD_FAIELD $alt_source $TXT_TRY_NEXT_LINK" + fi + done + + if [ ! -f "get-docker.sh" ]; then + log "$TXT_ALL_DOWNLOAD_ATTEMPTS_FAILED" + log "bash <(curl -sSL https://linuxmirrors.cn/docker.sh)" + exit 1 + fi + + sh get-docker.sh 2>&1 | tee -a "${CURRENT_DIR}/install.log" + + docker_config_folder="/etc/docker" + if [[ ! -d "$docker_config_folder" ]]; then + mkdir -p "$docker_config_folder" + fi + + set +e + docker version >/dev/null 2>&1 + local docker_ver_res=$? + set -e + if [[ $docker_ver_res -ne 0 ]]; then + log "$TXT_DOCKER_INSTALL_FAIL" + exit 1 + else + log "$TXT_DOCKER_INSTALL_SUCCESS" + if command -v systemctl &>/dev/null; then + systemctl enable docker 2>&1 | tee -a "${LOG_FILE}" + fi + configure_accelerator + fi + else + log "$TXT_CANNOT_SELECT_SOURCE" + exit 1 + fi + else + log "$TXT_REGIONS_OTHER_THAN_CHINA" + export DOWNLOAD_URL="https://download.docker.com" + curl -fsSL "https://get.docker.com" -o get-docker.sh + sh get-docker.sh 2>&1 | tee -a "${LOG_FILE}" + + log "$TXT_DOCKER_START_NOTICE" + if command -v systemctl &>/dev/null; then + systemctl enable docker; systemctl daemon-reload; systemctl start docker 2>&1 | tee -a "${LOG_FILE}" + else + service dockerd start 2>&1 | tee -a "${LOG_FILE}" + sleep 1 + fi + + docker_config_folder="/etc/docker" + if [[ ! -d "$docker_config_folder" ]]; then + mkdir -p "$docker_config_folder" + fi + + set +e + docker version >/dev/null 2>&1 + local docker_ver_res=$? + set -e + if [[ $docker_ver_res -ne 0 ]]; then + log "$TXT_DOCKER_INSTALL_FAIL" + exit 1 + else + log "$TXT_DOCKER_INSTALL_SUCCESS" + fi + fi + fi + + break + ;; + [nN]) + echo "$TXT_CANCEL_INSTALL_DOCKER" + exit 1 + ;; + *) + log "$TXT_INVALID_YN_INPUT" + continue + ;; + esac + done + fi +} + +usage() { + cat </dev/null || true + + echo "启动 openflare-agent 容器..." + if [[ -n "$AGENT_TOKEN" ]]; then + docker run -d --name openflare-agent --restart unless-stopped \ + -p 80:80 -p 443:443/tcp -p 443:443/udp \ + -v openflare-agent-pages:/data/var/lib/openflare/pages \ + -e OPENFLARE_SERVER_URL="${SERVER_URL}" \ + -e OPENFLARE_AGENT_TOKEN="${AGENT_TOKEN}" \ + ghcr.io/rain-kl/openflare-agent:latest + else + docker run -d --name openflare-agent --restart unless-stopped \ + -p 80:80 -p 443:443/tcp -p 443:443/udp \ + -v openflare-agent-pages:/data/var/lib/openflare/pages \ + -e OPENFLARE_SERVER_URL="${SERVER_URL}" \ + -e OPENFLARE_DISCOVERY_TOKEN="${DISCOVERY_TOKEN}" \ + ghcr.io/rain-kl/openflare-agent:latest + fi + + echo "" + echo "OpenFlare Agent (Docker) 安装成功!" + echo "您可以执行以下命令查看运行日志:" + echo " docker logs -f openflare-agent" + exit 0 +fi + +# Local installation starts below +if [[ -z "$OPENRESTY_PATH" ]]; then + if command -v openresty >/dev/null 2>&1; then + OPENRESTY_PATH="$(command -v openresty)" + elif [[ "$HAS_ARGS" == "false" ]]; then + read -p "未在 PATH 中检测到 openresty,请输入 OpenResty 二进制路径: " OPENRESTY_PATH + if [[ -z "$OPENRESTY_PATH" ]]; then + echo "Error: OpenResty binary path is required for local installation." + exit 1 + fi + else + echo "Error: openresty was not found in PATH. Install OpenResty first or pass --openresty-path." + exit 1 + fi +fi + +# Detect platform +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +if [[ "$OS" != "linux" && "$OS" != "darwin" ]]; then + echo "Unsupported OS: $OS" + exit 1 +fi + +if [[ ! -x "$OPENRESTY_PATH" ]]; then + echo "Error: OpenResty binary is not executable: ${OPENRESTY_PATH}" + exit 1 +fi + +ASSET_NAME="openflare-agent-${OS}-${ARCH}" +echo "Detected platform: ${OS}/${ARCH}" + +SYSTEMCTL_AVAILABLE="false" +if command -v systemctl >/dev/null 2>&1; then + SYSTEMCTL_AVAILABLE="true" +fi + +# Get latest release download URL +echo "Fetching latest release from ${REPO}..." +RELEASE_INFO=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest") +DOWNLOAD_URL=$(echo "$RELEASE_INFO" | grep -o "\"browser_download_url\"[[:space:]]*:[[:space:]]*\"[^\"]*${ASSET_NAME}\"" | grep -o 'https://[^"]*' || true) + +if [[ -z "$DOWNLOAD_URL" ]]; then + echo "Error: no matching asset '${ASSET_NAME}' found in latest release" + exit 1 +fi + +TAG=$(echo "$RELEASE_INFO" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"') +echo "Latest release: ${TAG}" + +# Download binary +echo "Downloading ${ASSET_NAME}..." +TMP_BINARY="$(mktemp "/tmp/openflare-agent.tmp.XXXXXX")" +cleanup() { + rm -f "$TMP_BINARY" +} +trap cleanup EXIT + +curl -fsSL -o "$TMP_BINARY" "$DOWNLOAD_URL" +chmod +x "$TMP_BINARY" + +SERVICE_WAS_ACTIVE="false" +if [[ "$OS" == "linux" && "$SYSTEMCTL_AVAILABLE" == "true" ]] && systemctl is-active --quiet "$SERVICE_NAME"; then + SERVICE_WAS_ACTIVE="true" + echo "Stopping running service before reinstall..." + systemctl stop "$SERVICE_NAME" +fi + +if [[ -d "$INSTALL_DIR" ]]; then + echo "Removing existing installation directory: ${INSTALL_DIR}" + rm -rf "$INSTALL_DIR" +fi + +ensure_runtime_user() { + if [[ "$OS" != "linux" ]]; then + return + fi + if id openflare >/dev/null 2>&1; then + return + fi + if command -v useradd >/dev/null 2>&1; then + useradd --system --home-dir "${INSTALL_DIR}/data" --shell /usr/sbin/nologin openflare + fi +} + +echo "Installing to ${INSTALL_DIR}..." +mkdir -p "${INSTALL_DIR}/data" +ensure_runtime_user + +mv -f "$TMP_BINARY" "${INSTALL_DIR}/openflare-agent" +trap - EXIT + +# Generate config +CONFIG_FILE="${INSTALL_DIR}/agent.json" +echo "Generating agent.json..." +if [[ -n "$AGENT_TOKEN" ]]; then + cat > "$CONFIG_FILE" < "$CONFIG_FILE" </dev/null 2>&1; then + chown -R openflare:openflare "${INSTALL_DIR}" +fi + +# Create systemd service +if [[ "$CREATE_SERVICE" == "true" && "$OS" == "linux" && -d /etc/systemd/system && "$SYSTEMCTL_AVAILABLE" == "true" ]]; then + echo "Creating systemd service..." + cat > /etc/systemd/system/openflare-agent.service < 0 { + fmt.Printf("OK flushed id=%d cpu=%.1f\n", rows[0].ID, rows[0].CPUUsagePercent) + return nil + } + time.Sleep(pollInterval) + } + return errors.New("not flushed within timeout") +} + +func assertLatestIncludes(ctx context.Context, nodeID string, now time.Time) error { + latest, err := repository.ListOpenFlareLatestMetricSnapshotsSince(ctx, "", now.Add(-time.Hour)) + if err != nil { + return fmt.Errorf("latest: %w", err) + } + for _, r := range latest { + if r != nil && r.NodeID == nodeID { + fmt.Println("OK latest-per-node includes node") + return nil + } + } + return fmt.Errorf("latest-per-node missing node %s", nodeID) +} diff --git a/scripts/swagger.sh b/scripts/swagger.sh index d5de289d..658398b0 100755 --- a/scripts/swagger.sh +++ b/scripts/swagger.sh @@ -1,6 +1,11 @@ #!/bin/sh +set -eu # execute this first # go install github.com/swaggo/swag/cmd/swag@latest -(cd backend && swag init -o docs --parseDependency --parseInternal) +( + cd backend + swag init -o docs --parseDependency --parseInternal +) +cp backend/docs/swagger.json backend/docs/swagger.yaml docs/ diff --git a/scripts/uninstall-agent.sh b/scripts/uninstall-agent.sh new file mode 100644 index 00000000..8cf50d6b --- /dev/null +++ b/scripts/uninstall-agent.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_DIR="/opt/openflare-agent" +SERVICE_NAME="openflare-agent" +INSTALL_METHOD="" + +usage() { + cat </dev/null 2>&1; then + if docker ps -a --format '{{.Names}}' | grep -Eq "^openflare-agent$"; then + echo "停止并移除 openflare-agent 容器..." + docker stop openflare-agent || true + docker rm -f openflare-agent || true + echo "已成功移除 openflare-agent 容器。" + else + echo "未检测到 openflare-agent 容器。" + fi + + # In interactive mode, ask if user wants to remove docker image + if [[ "$HAS_ARGS" == "false" ]]; then + read -p "是否删除 OpenFlare Agent 镜像 (ghcr.io/rain-kl/openflare-agent:latest)? (y/n) [n]: " remove_image_choice + remove_image_choice=${remove_image_choice:-n} + case "$remove_image_choice" in + [yY]) + echo "正在删除 Docker 镜像..." + docker rmi ghcr.io/rain-kl/openflare-agent:latest || true + ;; + *) + echo "保留 Docker 镜像。" + ;; + esac + fi + else + echo "Error: 未检测到 Docker 运行环境,无法进行 Docker 方式的卸载。" + exit 1 + fi + + echo "OpenFlare Agent (Docker) 卸载完成。" + exit 0 +fi + +if [[ -z "$INSTALL_DIR" || "$INSTALL_DIR" == "/" || "$INSTALL_DIR" == "." ]]; then + echo "Refusing to remove unsafe install directory: '${INSTALL_DIR}'" + exit 1 +fi + +AGENT_BINARY="${INSTALL_DIR}/openflare-agent" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" + +SYSTEMCTL_AVAILABLE="false" +if command -v systemctl >/dev/null 2>&1; then + SYSTEMCTL_AVAILABLE="true" +fi + +echo "Uninstalling OpenFlare Agent from ${INSTALL_DIR}..." + +if [[ "$SYSTEMCTL_AVAILABLE" == "true" ]]; then + if systemctl is-active --quiet "$SERVICE_NAME"; then + echo "Stopping service: ${SERVICE_NAME}" + systemctl stop "$SERVICE_NAME" + fi + + if systemctl is-enabled --quiet "$SERVICE_NAME" >/dev/null 2>&1; then + echo "Disabling service: ${SERVICE_NAME}" + systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true + fi +fi + +if command -v pgrep >/dev/null 2>&1; then + mapfile -t agent_pids < <(pgrep -f "$AGENT_BINARY" || true) + if (( ${#agent_pids[@]} > 0 )); then + echo "Stopping agent process: ${agent_pids[*]}" + kill "${agent_pids[@]}" || true + sleep 1 + + mapfile -t remaining_agent_pids < <(pgrep -f "$AGENT_BINARY" || true) + if (( ${#remaining_agent_pids[@]} > 0 )); then + echo "Force stopping remaining agent process: ${remaining_agent_pids[*]}" + kill -9 "${remaining_agent_pids[@]}" || true + fi + fi +fi + +if [[ -f "$SERVICE_FILE" ]]; then + echo "Removing service file: ${SERVICE_FILE}" + rm -f "$SERVICE_FILE" +fi + +if [[ "$SYSTEMCTL_AVAILABLE" == "true" ]]; then + systemctl daemon-reload || true + systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true +fi + +if [[ -d "$INSTALL_DIR" ]]; then + echo "Removing installation directory: ${INSTALL_DIR}" + rm -rf "$INSTALL_DIR" +else + echo "Installation directory not found, skipping: ${INSTALL_DIR}" +fi + +echo "Agent uninstall complete." +echo "" +echo "Local OpenResty was not modified. Remove it manually if you no longer need it." +echo "OpenFlare Agent uninstall finished." diff --git a/scripts/update_go_license.sh b/scripts/update_go_license.sh index 6279797d..28dfbe42 100755 --- a/scripts/update_go_license.sh +++ b/scripts/update_go_license.sh @@ -55,6 +55,10 @@ find_go_files() { -o -path './docs' \ -o -path './frontend/node_modules' \ -o -path './frontend/.next' \ + -o -path './frontend/out' \ + -o -path './internal/router/dist' \ + -o -path './internal/router/root/dist' \ + -o -path './backend/plugins/drivers/driver_http/dist' \ -o -path './plugins/drivers/driver_http/dist' \ -o -path './vendor' \) -prune \ -o -type f -name '*.go' -print @@ -75,7 +79,12 @@ local $/; my $src = <$in>; close $in; -my $header = +my $new_only_header = + "// Copyright 2026 Arctel.net\n" . + "// SPDX-License-Identifier: Apache-2.0"; + +my $modified_header = + "// Copyright 2025 linux.do\n" . "// Copyright 2026 Arctel.net\n" . "// SPDX-License-Identifier: Apache-2.0"; @@ -114,16 +123,30 @@ elsif ($body =~ m{\A(/\*.*?\*/)(\n*)}s) { $rest =~ s/\A\n+//; if ($block =~ /Licensed under the Apache License/ && $block =~ /Copyright /) { - $result = $prefix . $header . "\n\n" . $rest; + my $has_linux_do = ($block =~ /Copyright [^\n]*linux\.do/); + my $has_arctel = ($block =~ /Copyright [^\n]*Arctel\.net/); + my $has_modified = ($block =~ /Modified by Arctel\.net/); + + if ($has_linux_do && ($has_arctel || $has_modified)) { + $result = $prefix . $modified_header . "\n\n" . $rest; + } elsif ($has_arctel && !$has_linux_do) { + $result = $prefix . $new_only_header . "\n\n" . $rest; + } elsif ($has_linux_do && !$has_arctel && !$has_modified) { + my $h = "// Copyright 2025 linux.do\n" . + "// SPDX-License-Identifier: Apache-2.0"; + $result = $prefix . $h . "\n\n" . $rest; + } else { + $result = $prefix . $new_only_header . "\n\n" . $rest; + } } else { # Non-Apache block comment — prepend new header. - $result = $prefix . $header . "\n\n" . $block . "\n\n" . $rest; + $result = $prefix . $new_only_header . "\n\n" . $block . "\n\n" . $rest; } } # --- Case 3: no header at all --- else { $body =~ s/\A\n+//; - $result = $prefix . $header . "\n\n" . $body; + $result = $prefix . $new_only_header . "\n\n" . $body; } open my $fh, '>', $out or die "write $out: $!";