commit 18216953e722a30643a2c67f69b009a833414450 Author: KenanZhu Date: Wed Nov 4 09:58:28 2026 +0800 Initialize AutoLibrary website with Gitea deployment diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..02aed88 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +/frontend/** text=auto eol=lf +*.sh text eol=lf +*.service text eol=lf +*.yml text eol=lf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..6bafbbb --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: Deploy AutoLibrary Website + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: autolibrary-production + steps: + - name: Check out the pushed commit + uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + github-server-url: ${{ gitea.server_url }} + ref: ${{ gitea.sha }} + + - name: Build and test + shell: bash + run: bash deploy/build.sh + + - name: Deploy and verify + shell: bash + env: + RELEASE_SHA: ${{ gitea.sha }} + AUTOLIBRARY_PORT: ${{ vars.AUTOLIBRARY_PORT || '3003' }} + run: bash deploy/publish.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9c9263 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +/.deploy/ +/frontend/dist/ +/webserver +.preview/ +.legacy/ +/.env +/.env.* +/frontend/.env +/frontend/.env.* +.vscode/ +.claude/ +logs/ +*.log +*.exe +/config.json +/backend/data/ +/casdoor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a047705 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# AutoLibrary Website + +北京建筑大学图书馆自动化工具的官网、软件下载中心与社区主题市场。 + +- 产品仓库: +- 用户手册: +- 发行版本: + +## 架构 + +前端采用 Vue 3、TypeScript、Vite、Vue Router、Pinia 和 Lucide 图标。所有字体、图标和图片随构建产物提供,无需第三方 CDN。页面使用统一的布局、表单和状态组件,深浅主题由 CSS 语义变量管理。 + +Go/Gin 前端服务提供构建产物并将 `/api/*` 代理到后端;后端保留 GORM/SQLite、Casdoor 和本地主题文件存储。既可一起启动,也可分别部署。当前面向单实例运行。 + +```text +frontend/ + src/ + api/ # 请求、响应、下载和错误处理 + components/ # 全站与业务共享组件 + composables/ # 画廊等可复用交互逻辑 + pages/ # 按路由拆分的页面 + stores/ # 账户与外观状态 + styles/ # 页面专用样式 + site.ts # 项目链接、URL 和日期工具 + types.ts # API 数据类型 + styles.css # 双主题变量与响应式样式 + public/assets/ # 原有品牌标识与实机截图 + dist/ # 生产构建产物(生成) +backend/ + handlers/themes_handler/ # 认证、查询、上传、更新、审核、下载 + services/ # 主题打包、文件存储、事务协调、用户缓存 + database/ # SQLite 初始化 + models/ # 数据模型 +``` + +## 本地开发 + +需要 Go 1.25.6+ 和 Node.js 22.13+(推荐当前 Node LTS,Vite 要求 Node 22.12+,ESLint 要求 22.13+)。从项目根目录运行: + +```sh +# 首次配置;已有 config.json 时保留原文件 +cp config.example.json config.json +npm --prefix frontend ci +go run . backend +``` + +在另一个终端启动前端: + +```sh +npm --prefix frontend run dev +``` + +开发地址为 `http://127.0.0.1:5173`。默认代理到 `http://127.0.0.1:8080`;后端端口不同时,在 `frontend/.env.local` 设置 `API_PROXY_TARGET=http://127.0.0.1:实际端口`。无需在前端添加服务器密钥。 + +登录与注册需要配置并启动 Casdoor。未配置时,浏览下载和主题市场仍可用;账户操作会显示服务未启用。管理员审核统一使用 Casdoor 管理员账户。 + +## 构建与检查 + +```sh +npm --prefix frontend run typecheck +npm --prefix frontend run check +npm --prefix frontend run build +go test ./... +go build -o webserver . +./webserver start +``` + +`build` 已包含类型、ESLint 和 Prettier 检查,产物位于 `frontend/dist`。使用 `npm --prefix frontend run format` 统一前端源码格式。没有增加测试用例或测试工具链。Go 检查用于验证已有包可以编译。 + +生产环境默认访问 `http://127.0.0.1:3000`,实际地址以 `config.json` 为准。`frontend` 和 `backend` 子命令支持独立启动,均可使用 `--config /absolute/path/config.json`。请将工作目录设置为项目部署目录。 + +## 发行与下载数据 + +版本数据库是官网版本信息的唯一来源。首页通过 `GET /api/download/latest` 获取最新已发布的稳定版(按 `releaseDate`、ID 降序;无稳定版时 `data: null`),下载列表保持原有分页接口。`/api` 前缀由 Go 前端或 Vite 代理移除,直接访问后端时使用 `/download/latest` 等路径。 + +版本模型新增以下字段,启动时由 GORM 增量添加列,不删除原字段或数据: + +| JSON 字段 | 用途 | +| --- | --- | +| `giteaZipUrl` | Gitea Windows 便携包 | +| `giteaInstallerUrl` | Gitea Windows 安装程序 | +| `giteaDmgUrl` | Gitea macOS 安装包 | +| `releaseNotesUrl` | 对应版本的发行说明 | + +使用已有受 API Key 保护的 `POST/PUT /download/versions` 接口维护这些字段。提供真实的 Gitea 附件地址;不要仅将历史 GitHub URL 替换域名。新页面没有可用附件地址时,提供 Gitea 发行列表入口,云盘下载照常保留。原 `github*Url` 字段继续供已有 API 调用使用,新页面不再展示 GitHub 下载入口。 + +独立更新日志已移除,`/changelog` 永久跳转 `/downloads`。发行说明在 Gitea 查看;公告接口及其数据库保留。个人下载历史尚未实现,本次没有扩展该功能。 + +## 发布与维护 + +网站源码推送到 Gitea 的 `main` 后,由生产 Runner 构建、部署并检查服务。首次接入见 [Gitea 自动部署](docs/gitea-deployment.md)。 + +完整部署、备份和回滚步骤见 [Ubuntu 部署说明](docs/migration-to-ubuntu.md)。配置和运行时数据与前端构建分开保存;本地现有数据库、主题文件和 Casdoor 配置不需要重建。 + +迁移前的静态页面、脚本和混淆工具已保存在项目内 `.legacy/`,不参与构建或运行,可用于核对历史内容。 + +主题上传支持最大 8 MB 的 `.qss` 和最大 4 MB 的 PNG/JPG 预览图,后端总请求体上限 16 MB。主题和打包文件一起保存;文件、元数据或预览更新后重新进入审核队列。退出时先停止接收请求,再关闭数据库。 diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..af5d2dd --- /dev/null +++ b/backend/backend.go @@ -0,0 +1,172 @@ +package backend + +import ( + "context" + "errors" + "log" + "net" + "net/http" + "path/filepath" + "time" + + "autolibrary.top/webserver/backend/database/bulletins_database" + "autolibrary.top/webserver/backend/database/themes_database" + "autolibrary.top/webserver/backend/database/versions_database" + "autolibrary.top/webserver/backend/handlers/bulletins_handler" + "autolibrary.top/webserver/backend/handlers/errors_handler" + "autolibrary.top/webserver/backend/handlers/health_handler" + "autolibrary.top/webserver/backend/handlers/themes_handler" + "autolibrary.top/webserver/backend/handlers/versions_handler" + "autolibrary.top/webserver/backend/middlewares/auth_middleware" + "autolibrary.top/webserver/backend/middlewares/casdoor_middleware" + "autolibrary.top/webserver/backend/services/theme_files" + "autolibrary.top/webserver/backend/services/users_service" + "autolibrary.top/webserver/config" + "autolibrary.top/webserver/utils" + + "github.com/casdoor/casdoor-go-sdk/casdoorsdk" + "github.com/gin-gonic/gin" +) + +// StartBackend : initializes the backend server. +func StartBackend() error { return StartBackendContext(context.Background()) } + +func StartBackendContext(ctx context.Context) error { + defer EndBackend() + cfg := config.GetConfig() + gin.SetMode(gin.ReleaseMode) + err := versions_database.InitDB(cfg.Backend.Database.Versions.Path) + if err != nil { + log.Printf("[E] versions database initialization failed: %v", err) + return err + } + err = bulletins_database.InitDB(cfg.Backend.Database.Bulletins.Path) + if err != nil { + log.Printf("[E] bulletins database initialization failed: %v", err) + return err + } + err = themes_database.InitDB(cfg.Backend.Database.Themes.Path) + if err != nil { + log.Printf("[E] themes database initialization failed: %v", err) + return err + } + files, err := theme_files.NewStore(cfg.Backend.Database.ThemesFiles.Path) + if err != nil { + return err + } + users := users_service.New(cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != "") + themes := themes_handler.New(themes_database.DB, cfg, files, users) + + // Pre-initialize Casdoor SDK and user display-name cache + if cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != "" { + casdoorsdk.InitConfig( + cfg.Backend.Casdoor.Endpoint, + cfg.Backend.Casdoor.ClientID, + cfg.Backend.Casdoor.ClientSecret, + cfg.Backend.Casdoor.Certificate, + cfg.Backend.Casdoor.Organization, + cfg.Backend.Casdoor.Application, + ) + casdoorsdk.SetHttpClient(&http.Client{Timeout: 15 * time.Second}) + } + + r := gin.New() + r.MaxMultipartMemory = 8 << 20 + r.Use(func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20); c.Next() }) + r.Use(gin.Recovery()) + r.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Requested-With") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + templateDir := filepath.Join(utils.GetRootPath(), "backend", "templates") + templateFiles := []string{ + filepath.Join(templateDir, "404.html"), + } + r.LoadHTMLFiles(templateFiles...) + + public := r.Group("/") + { + public.GET("/health", health_handler.GetHealth) + public.GET("/download/latest", versions_handler.GetLatestDownload) + public.GET("/download/versions", versions_handler.GetDownloadVersions) + public.GET("/download/versions/:id", versions_handler.GetDownloadVersionByID) + public.GET("/bulletins", bulletins_handler.GetBulletins) + public.GET("/bulletins/:id", bulletins_handler.GetBulletinByID) + } + + // Marketplace public routes + mp := r.Group("/marketplace") + { + mp.GET("/auth/config", themes.GetAuthConfig) + mp.GET("/auth/captcha", themes.GetCaptcha) + mp.POST("/auth/login", themes.LoginProxy) + mp.POST("/auth/signup", themes.SignupProxy) + mp.GET("/themes/featured", themes.GetFeaturedThemes) + mp.GET("/themes/preview/:filename", themes.ServePreview) + mp.GET("/themes", themes.GetThemes) + mp.GET("/themes/:id", themes.GetThemeByID) + mp.GET("/themes/:id/download", themes.DownloadTheme) + } + + // Marketplace admin routes (API key) + mpAdmin := r.Group("/marketplace") + mpAdmin.Use(auth_middleware.APIKeyAuth()) + { + mpAdmin.PUT("/admin/themes/:id/review", themes.ReviewTheme) + mpAdmin.GET("/admin/themes/pending", themes.GetPendingThemes) + } + + // Marketplace user routes (Casdoor JWT) + mpUser := r.Group("/marketplace") + mpUser.Use(casdoor_middleware.CasdoorAuth()) + { + mpUser.POST("/themes", themes.PostTheme) + mpUser.PUT("/themes/:id", themes.PutThemeByID) + mpUser.DELETE("/themes/:id", themes.DeleteThemeByID) + mpUser.GET("/user/check-theme-name", themes.CheckThemeName) + mpUser.POST("/themes/:id/preview", themes.UploadPreview) + mpUser.POST("/themes/:id/file", themes.ReplaceThemeFile) + mpUser.GET("/user/themes", themes.GetUserThemes) + mpUser.GET("/user/downloads", themes.GetUserDownloads) + mpUser.GET("/user/role", themes.GetUserRole) + } + + // Marketplace admin routes (Casdoor JWT + admin check) + mpAdminUser := r.Group("/marketplace") + mpAdminUser.Use(casdoor_middleware.CasdoorAdminAuth()) + { + mpAdminUser.GET("/admin/pending", themes.GetPendingThemes) + mpAdminUser.PUT("/admin/review/:id", themes.ReviewTheme) + mpAdminUser.GET("/admin/download/:id", themes.AdminDownloadTheme) + } + + protected := r.Group("/") + protected.Use(auth_middleware.APIKeyAuth()) + { + protected.POST("/download/versions", versions_handler.PostDownloadVersion) + protected.PUT("/download/versions/:id", versions_handler.PutDownloadVersionByID) + protected.DELETE("/download/versions/:id", versions_handler.DeleteDownloadVersionByID) + + protected.POST("/bulletins", bulletins_handler.PostBulletin) + protected.PUT("/bulletins/:id", bulletins_handler.PutBulletinByID) + protected.DELETE("/bulletins/:id", bulletins_handler.DeleteBulletinByID) + } + r.NoRoute(errors_handler.GetNotFoundError) + + backend_address := net.JoinHostPort(cfg.Backend.Host, cfg.Backend.Port) + log.Printf("[I] backend server is running on: http://%s", backend_address) + + return utils.ServeHTTP(ctx, backend_address, r) +} + +// EndBackend : closes the database connection. +func EndBackend() error { + return errors.Join(versions_database.CloseDB(), bulletins_database.CloseDB(), themes_database.CloseDB()) +} diff --git a/backend/database/all.go b/backend/database/all.go new file mode 100644 index 0000000..8921b75 --- /dev/null +++ b/backend/database/all.go @@ -0,0 +1,7 @@ +package database + +import ( + _ "autolibrary.top/webserver/backend/database/versions_database" + _ "autolibrary.top/webserver/backend/database/bulletins_database" + _ "autolibrary.top/webserver/backend/database/themes_database" +) \ No newline at end of file diff --git a/backend/database/bulletins_database/database.go b/backend/database/bulletins_database/database.go new file mode 100644 index 0000000..dfe4387 --- /dev/null +++ b/backend/database/bulletins_database/database.go @@ -0,0 +1,50 @@ +package bulletins_database + +import ( + "log" + + "autolibrary.top/webserver/backend/models/bulletins_model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB // global bulletins database instance + +func InitDB(dsn string) error { + var err error + + DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + log.Printf("[E] unable to connect to bulletins database: %v", err) + return err + } + + err = DB.AutoMigrate( + &bulletins_model.Bulletin{}, + ) + if err != nil { + log.Printf("[E] bulletins database migration failed: %v", err) + return err + } + + log.Printf("[I] bulletins database initialization completed") + return nil +} + +func CloseDB() error { + sqlDB, err := DB.DB() + if err != nil { + log.Printf("[E] unable to get bulletins database connection: %v", err) + return err + } + + err = sqlDB.Close() + if err != nil { + log.Printf("[E] unable to close bulletins database connection: %v", err) + return err + } + + log.Printf("[I] bulletins database connection closed") + return nil +} \ No newline at end of file diff --git a/backend/database/themes_database/database.go b/backend/database/themes_database/database.go new file mode 100644 index 0000000..5cca0c6 --- /dev/null +++ b/backend/database/themes_database/database.go @@ -0,0 +1,54 @@ +package themes_database + +import ( + "log" + + "autolibrary.top/webserver/backend/models/themes_model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDB(dsn string) error { + var err error + + DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + log.Printf("[E] unable to connect to themes database: %v", err) + return err + } + + err = DB.AutoMigrate( + &themes_model.Theme{}, + ) + if err != nil { + log.Printf("[E] themes database migration failed: %v", err) + return err + } + + log.Printf("[I] themes database initialization completed") + return nil +} + +func CloseDB() error { + if DB == nil { + return nil + } + + db, err := DB.DB() + if err != nil { + log.Printf("[E] unable to get themes database connection: %v", err) + return err + } + + err = db.Close() + if err != nil { + log.Printf("[E] unable to close themes database connection: %v", err) + return err + } + + log.Printf("[I] themes database connection closed") + return nil +} diff --git a/backend/database/versions_database/database.go b/backend/database/versions_database/database.go new file mode 100644 index 0000000..a5052ac --- /dev/null +++ b/backend/database/versions_database/database.go @@ -0,0 +1,54 @@ +package versions_database + +import ( + "log" + + "autolibrary.top/webserver/backend/models/versions_model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB // global versions database instance + +func InitDB(dsn string) error { + var err error + + DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + log.Printf("[E] unable to connect to versions database: %v", err) + return err + } + + err = DB.AutoMigrate( + &versions_model.DownloadVersion{}, + ) + if err != nil { + log.Printf("[E] versions database migration failed: %v", err) + return err + } + + log.Printf("[I] versions database initialization completed") + return nil +} + +func CloseDB() error { + if DB == nil { + return nil + } + + db, err := DB.DB() + if err != nil { + log.Printf("[E] unable to get versions database connection: %v", err) + return err + } + + err = db.Close() + if err != nil { + log.Printf("[E] unable to close versions database connection: %v", err) + return err + } + + log.Printf("[I] versions database connection closed") + return nil +} diff --git a/backend/handlers/all.go b/backend/handlers/all.go new file mode 100644 index 0000000..b843b7e --- /dev/null +++ b/backend/handlers/all.go @@ -0,0 +1,9 @@ +package handlers + +import ( + _ "autolibrary.top/webserver/backend/handlers/errors_handler" + _ "autolibrary.top/webserver/backend/handlers/health_handler" + _ "autolibrary.top/webserver/backend/handlers/versions_handler" + _ "autolibrary.top/webserver/backend/handlers/bulletins_handler" + _ "autolibrary.top/webserver/backend/handlers/themes_handler" +) \ No newline at end of file diff --git a/backend/handlers/bulletins_handler/handler.go b/backend/handlers/bulletins_handler/handler.go new file mode 100644 index 0000000..0bd12c7 --- /dev/null +++ b/backend/handlers/bulletins_handler/handler.go @@ -0,0 +1,310 @@ +package bulletins_handler + +import ( + "log" + "net/http" + "strconv" + "time" + + "autolibrary.top/webserver/utils" + "autolibrary.top/webserver/backend/database/bulletins_database" + "autolibrary.top/webserver/backend/models/bulletins_model" + + "github.com/gin-gonic/gin" +) + +/* + These handlers are used to manage bulletins. +*/ + +// GET /bulletins?date={yyyy-MM-dd}&time={HH:mm:ss}&range_hour={0 try to get bulletins, but got invalid (date=%s, time=%s, tz=%s) parameters: %v", dateStr, timeStr, loc.String(), err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > try to get bulletins, but got invalid parameters: 'date' or 'time'", + "data": nil, + }) + return + } + rangeHours, err := strconv.Atoi(rangeHourStr) + if err != nil { + log.Printf("[E] GET > try to get bulletins, but got invalid (range_hour=%s) parameter: %v", rangeHourStr, err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > try to get bulletins, but got invalid parameter: 'range_hour'", + "data": nil, + }) + return + } + + startOfDateTime := dateTime + endOfDateTime := startOfDateTime.Add(time.Duration(rangeHours)*time.Hour) + startOfDateTimeStr := startOfDateTime.Format(time.RFC3339) + endOfDateTimeStr := endOfDateTime.Format(time.RFC3339) + + var bulletins []bulletins_model.Bulletin + result := bulletins_database.DB.Where( + "date_time >= ? AND date_time < ? AND is_deleted = ?", startOfDateTime, endOfDateTime, false, + ).Find(&bulletins) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get bulletins from %s to %s: %v", startOfDateTimeStr, endOfDateTimeStr, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get bulletins", + "data": nil, + }) + return + } + + var deleteIds []int64 + result = bulletins_database.DB.Model(bulletins_model.Bulletin{}).Where( + "date_time >= ? AND date_time < ? AND is_deleted = ?", startOfDateTime, endOfDateTime, true, + ).Pluck("id", &deleteIds) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get bulletins from %s to %s: %v", startOfDateTimeStr, endOfDateTimeStr, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get bulletins", + "data": nil, + }) + return + } + + data := gin.H{ + "bulletins": bulletins, + "delete_ids": deleteIds, + } + + log.Printf("[I] GET > get bulletins from %s to %s successfully", startOfDateTimeStr, endOfDateTimeStr) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get bulletins successful", + "data": data, + }) +} + +// GET /bulletins/:id +func GetBulletinByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] GET > try to get bulletin, but got invalid id parameter: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > try to get bulletin, but got invalid id", + "data": nil, + }) + return + } + + var bulletin bulletins_model.Bulletin + result := bulletins_database.DB.Where("is_deleted = ?", false).First(&bulletin, id) + if result.Error != nil { + log.Printf("[E] GET > bulletin (id=%d) can not found: %v", id, result.Error) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "GET > bulletin can not found", + "data": nil, + }) + return + } + + log.Printf("[I] GET > get bulletin (id=%d) successfully", id) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get bulletin successful", + "data": bulletin, + }) +} + +// POST /bulletins +func PostBulletin(c *gin.Context) { + var bulletin bulletins_model.Bulletin + if err := c.ShouldBindJSON(&bulletin); err != nil { + log.Printf("[E] POST > try to create bulletin, but got invalid request data: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "POST > try to create bulletin, but got invalid request data", + "data": nil, + }) + return + } + + bulletin.CreateAt = utils.NowInShanghai() + // set default value to make sure the bulletin is not edited and not deleted before + // get into the database + bulletin.IsEdited = false + bulletin.IsDeleted = false + result := bulletins_database.DB.Create(&bulletin) + if result.Error != nil { + log.Printf("[E] POST > occur an error when create bulletin: %v", result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "POST > occur an error when create bulletin", + "data": nil, + }) + return + } + dataTimeStr := bulletin.DateTime.Format(time.RFC3339) + + id := strconv.FormatUint(uint64(bulletin.ID), 10) + log.Printf("[I] POST > create bulletin (id=%s, date_time=%s) successful", id, dataTimeStr) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "POST > create bulletin successful", + "data": bulletin, + }) +} + +// PUT /bulletins/:id +func PutBulletinByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] PUT > try to update bulletin, but got invalid id parameter: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "PUT > try to update bulletin, but got invalid id", + "data": nil, + }) + return + } + + var updateBulletin bulletins_model.Bulletin + if err := c.ShouldBindJSON(&updateBulletin); err != nil { + log.Printf("[E] PUT > try to update bulletin, but got invalid request data: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "PUT > try to update bulletin, but got invalid request data", + "data": nil, + }) + return + } + + var existingBulletin bulletins_model.Bulletin + result := bulletins_database.DB.Where("is_deleted = ?", false).First(&existingBulletin, id) + if result.Error != nil { + log.Printf("[E] PUT > bulletin (id=%d) can not found: %v", id, result.Error) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "PUT > bulletin can not found", + "data": nil, + }) + return + } + + // keep the original date time + updateBulletin.DateTime = existingBulletin.DateTime + // set last edit time to current time + updateBulletin.LastEditAt = utils.NowInShanghai() + // set is edited to true + updateBulletin.IsEdited = true + + result = bulletins_database.DB.Model(&existingBulletin).Where("id = ?", id).Updates(updateBulletin) + if result.Error != nil { + log.Printf("[E] PUT > occur an error when update bulletin (id=%d): %v", id, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "PUT > occur an error when update bulletin", + "data": nil, + }) + return + } + + // post check after update + var updatedBulletin bulletins_model.Bulletin + if result = bulletins_database.DB.Where("is_deleted = ?", false).First(&updatedBulletin, id); result.Error == nil { + log.Printf("[I] PUT > update bulletin (id=%d) successful", id) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "PUT > update bulletin successful", + "data": updatedBulletin, + }) + } else { + log.Printf("[E] PUT > update successful but failed to retrieve updated bulletin (id=%d): %v", id, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "PUT > update successful but failed to retrieve updated bulletin", + "data": nil, + }) + } +} + +// DELETE /bulletins/:id +func DeleteBulletinByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] DELETE > try to delete bulletin, but got invalid id: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "DELETE > try to delete bulletin, but got invalid id", + "data": nil, + }) + return + } + + // soft delete, actually is a update operation + var existingBulletin bulletins_model.Bulletin + result := bulletins_database.DB.Where("is_deleted = ?", false).First(&existingBulletin, id) + if result.Error != nil { + log.Printf("[E] DELETE > bulletin (id=%d) can not found: %v", id, result.Error) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "DELETE > bulletin can not found", + "data": nil, + }) + return + } + // set is_deleted to true + existingBulletin.IsDeleted = true + + result = bulletins_database.DB.Model(&existingBulletin).Where("id = ?", id).Updates(existingBulletin) + if result.Error != nil { + log.Printf("[E] DELETE > occur an error when soft delete bulletin (id=%d): %v", id, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "DELETE > occur an error when soft delete bulletin", + "data": nil, + }) + return + } + + // post check after soft delete + var deletedBulletin bulletins_model.Bulletin + if result = bulletins_database.DB.Where("is_deleted = ?", true).First(&deletedBulletin, id); result.Error == nil { + log.Printf("[I] DELETE > soft delete bulletin (id=%d) successful", id) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "DELETE > soft delete bulletin successful", + "data": deletedBulletin, + }) + } else { + log.Printf("[E] DELETE > soft delete successful but failed to retrieve deleted bulletin (id=%d): %v", id, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "DELETE > soft delete successful but failed to retrieve deleted bulletin", + "data": nil, + }) + } +} \ No newline at end of file diff --git a/backend/handlers/errors_handler/handler.go b/backend/handlers/errors_handler/handler.go new file mode 100644 index 0000000..57648b8 --- /dev/null +++ b/backend/handlers/errors_handler/handler.go @@ -0,0 +1,45 @@ +package errors_handler + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// 404 - Not Found +func GetNotFoundError(c *gin.Context) { + errorData := gin.H{ + "code": http.StatusNotFound, + "msg": c.Request.Method + " > the requested API endpoint was not found", + "data": gin.H{ + "path": c.Request.URL.Path, + "method": c.Request.Method, + "endpoints": gin.H{ + "health": "/health", + "downloads": "/download/versions", + }, + }, + } + + acceptHeader := c.GetHeader("Accept") + if strings.Contains(acceptHeader, "text/html") { + // for browser requests, return HTML + jsonBytes, err := json.Marshal(errorData) + if err != nil { + jsonBytes = []byte(`{"error": "json_serialization_failed"}`) + } + jsonStr := string(jsonBytes) + c.HTML(http.StatusNotFound, "404.html", gin.H{ + "title": "404 - Not Found", + "path": c.Request.URL.Path, + "method": c.Request.Method, + "baseURL": c.Request.Host, + "jsonData": jsonStr, + }) + return + } + // for General requests (no text/html in Accept header), return JSON + c.JSON(http.StatusNotFound, errorData) +} diff --git a/backend/handlers/health_handler/handler.go b/backend/handlers/health_handler/handler.go new file mode 100644 index 0000000..bfc0cf3 --- /dev/null +++ b/backend/handlers/health_handler/handler.go @@ -0,0 +1,22 @@ +package health_handler + +import ( + "log" + "time" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GET /health +func GetHealth(c *gin.Context) { + log.Printf("[I] HEALTH > get backend server health") + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > backend server is running ok", + "data": gin.H{ + "timestamp": time.Now().Unix(), + "status": "healthy", + }, + }) +} \ No newline at end of file diff --git a/backend/handlers/themes_handler/auth.go b/backend/handlers/themes_handler/auth.go new file mode 100644 index 0000000..950aacf --- /dev/null +++ b/backend/handlers/themes_handler/auth.go @@ -0,0 +1,226 @@ +package themes_handler + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + + "github.com/dchest/captcha" + "github.com/gin-gonic/gin" +) + +func (h *Handler) GetUserRole(c *gin.Context) { + userID, _ := c.Get("user_id") + userName, _ := c.Get("user_name") + displayName, _ := c.Get("display_name") + isAdmin, _ := c.Get("is_admin") + userTag, _ := c.Get("user_tag") + + isAdminBool := false + if isAdmin != nil { + isAdminBool = isAdmin.(bool) + } + displayNameStr := "" + if displayName != nil { + displayNameStr = displayName.(string) + } + userTagStr := "" + if userTag != nil { + userTagStr = userTag.(string) + } + + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > user role", + "data": gin.H{ + "user_id": userID, + "user_name": userName, + "display_name": displayNameStr, + "is_admin": isAdminBool, + "is_verified": isAdminBool || userTagStr == "verified", + }, + }) +} + +// GET /api/user/downloads (stretch goal) + +func (h *Handler) GetUserDownloads(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > download history not yet implemented", + "data": []interface{}{}, + }) +} + +// GET /api/marketplace/auth/captcha + +func (h *Handler) GetCaptcha(c *gin.Context) { + id := captcha.NewLen(4) + var buf bytes.Buffer + if err := captcha.WriteImage(&buf, id, 150, 50); err != nil { + log.Printf("[E] CAPTCHA > failed to generate: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "captcha generation failed", "data": nil}) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > captcha", + "data": gin.H{ + "captchaId": id, + "captchaImage": "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), + }, + }) +} + +// GET /api/marketplace/auth/config + +func (h *Handler) GetAuthConfig(c *gin.Context) { + cfg := h.Config + casdoorConfigured := cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != "" + + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > auth config", + "data": gin.H{ + "casdoor_configured": casdoorConfigured, + "casdoor_endpoint": cfg.Backend.Casdoor.Endpoint, + "casdoor_client_id": cfg.Backend.Casdoor.ClientID, + "casdoor_organization": cfg.Backend.Casdoor.Organization, + "casdoor_application": cfg.Backend.Casdoor.Application, + }, + }) +} + +// POST /api/marketplace/auth/login + +func (h *Handler) LoginProxy(c *gin.Context) { + cfg := h.Config + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "PUT > invalid request body", "data": nil}) + return + } + + // Proxy to Casdoor OAuth token endpoint + tokenURL := cfg.Backend.Casdoor.Endpoint + "/api/login/oauth/access_token" + values := url.Values{ + "grant_type": {"password"}, + "client_id": {cfg.Backend.Casdoor.ClientID}, + "client_secret": {cfg.Backend.Casdoor.ClientSecret}, + "username": {req.Username}, + "password": {req.Password}, + "scope": {"read"}, + } + reqHTTP, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, tokenURL, strings.NewReader(values.Encode())) + if err != nil { + fail(c, http.StatusBadGateway, "Invalid authentication server configuration") + return + } + reqHTTP.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := h.HTTP.Do(reqHTTP) + if err != nil { + log.Printf("[E] AUTH > login proxy error: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"code": http.StatusBadGateway, "msg": "auth server unreachable", "data": nil}) + return + } + defer resp.Body.Close() + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if readErr != nil { + fail(c, http.StatusBadGateway, "Unable to read authentication response") + return + } + if resp.StatusCode != http.StatusOK { + c.JSON(http.StatusUnauthorized, gin.H{"code": http.StatusUnauthorized, "msg": "invalid credentials", "data": nil}) + return + } + + c.Header("Content-Type", "application/json") + c.String(http.StatusOK, string(body)) +} + +// POST /api/marketplace/auth/signup + +func (h *Handler) SignupProxy(c *gin.Context) { + cfg := h.Config + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Name string `json:"name"` + CaptchaId string `json:"captchaId"` + CaptchaCode string `json:"captchaCode"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "PUT > invalid request body", "data": nil}) + return + } + if req.Username == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "用户名和密码为必填项", "data": nil}) + return + } + + // Validate captcha + if !captcha.VerifyString(req.CaptchaId, req.CaptchaCode) { + c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "验证码错误或已过期", "data": nil}) + return + } + + // Proxy to Casdoor signup API + signupData := map[string]interface{}{ + "organization": cfg.Backend.Casdoor.Organization, + "username": req.Username, + "password": req.Password, + "application": cfg.Backend.Casdoor.Application, + } + if req.Email != "" { + signupData["email"] = req.Email + } + if req.Name != "" { + signupData["name"] = req.Name + } + signupURL := cfg.Backend.Casdoor.Endpoint + "/api/signup" + signupBody, _ := json.Marshal(signupData) + reqHTTP, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, signupURL, bytes.NewReader(signupBody)) + if err != nil { + fail(c, http.StatusBadGateway, "Invalid authentication server configuration") + return + } + reqHTTP.Header.Set("Content-Type", "application/json") + resp, err := h.HTTP.Do(reqHTTP) + if err != nil { + log.Printf("[E] AUTH > signup proxy error: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"code": http.StatusBadGateway, "msg": "auth server unreachable", "data": nil}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var result map[string]interface{} + json.Unmarshal(body, &result) + + if status, ok := result["status"]; !ok || status != "ok" { + msg := "注册失败" + if m, ok := result["msg"]; ok { + msg = fmt.Sprintf("%v", m) + } + c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": msg, "data": nil}) + return + } + + log.Printf("[I] AUTH > signup successful for user: %s", req.Username) + c.Header("Content-Type", "application/json") + c.String(http.StatusOK, string(body)) +} diff --git a/backend/handlers/themes_handler/dependencies.go b/backend/handlers/themes_handler/dependencies.go new file mode 100644 index 0000000..407cd47 --- /dev/null +++ b/backend/handlers/themes_handler/dependencies.go @@ -0,0 +1,28 @@ +package themes_handler + +import ( + "net/http" + "time" + + "autolibrary.top/webserver/backend/services/theme_files" + "autolibrary.top/webserver/backend/services/themes_service" + "autolibrary.top/webserver/backend/services/users_service" + "autolibrary.top/webserver/config" + + "gorm.io/gorm" +) + +// Handler shares explicit dependencies across the marketplace HTTP handlers. +// A separate instance can be constructed for each database and storage root. +type Handler struct { + DB *gorm.DB + Config *config.Config + Files *theme_files.Store + Users *users_service.Cache + HTTP *http.Client + Themes *themes_service.Service +} + +func New(db *gorm.DB, cfg *config.Config, files *theme_files.Store, users *users_service.Cache) *Handler { + return &Handler{DB: db, Config: cfg, Files: files, Users: users, Themes: &themes_service.Service{DB: db, Files: files}, HTTP: &http.Client{Timeout: 15 * time.Second}} +} diff --git a/backend/handlers/themes_handler/download.go b/backend/handlers/themes_handler/download.go new file mode 100644 index 0000000..fcfda4a --- /dev/null +++ b/backend/handlers/themes_handler/download.go @@ -0,0 +1,71 @@ +package themes_handler + +import ( + "errors" + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "autolibrary.top/webserver/backend/models/themes_model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func (h *Handler) DownloadTheme(c *gin.Context) { h.serveTheme(c, true) } +func (h *Handler) AdminDownloadTheme(c *gin.Context) { h.serveTheme(c, false) } + +func (h *Handler) serveTheme(c *gin.Context, public bool) { + id, err := parseThemeID(c) + if err != nil { + fail(c, http.StatusBadRequest, "Invalid theme ID") + return + } + var theme themes_model.Theme + query := h.DB + if public { + query = query.Where("status = ?", themes_model.ThemeApproved) + } + err = query.First(&theme, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + fail(c, 404, "Theme not found") + return + } + if err != nil { + fail(c, 500, "Unable to load theme") + return + } + path := h.Themes.ArchivePath(theme.ID) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + fail(c, 404, "Theme file not found") + return + } + if public { + h.DB.Model(&theme).UpdateColumn("download_count", gorm.Expr("download_count + 1")) + } + version := strings.TrimLeft(theme.Version, "vV") + name := fmt.Sprintf("%s_v%s.altheme", theme.Name, version) + c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name})) + c.Header("Content-Type", "application/zip") + c.File(path) +} + +func (h *Handler) ServePreview(c *gin.Context) { + name := c.Param("filename") + if name == "." || name == ".." || strings.ContainsAny(name, `/\`) || filepath.Base(name) != name { + fail(c, 400, "Invalid preview filename") + return + } + path := filepath.Join(h.Files.Root(), "previews", name) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + fail(c, 404, "Preview not found") + return + } + c.Header("Cache-Control", "public, max-age=3600") + c.File(path) +} diff --git a/backend/handlers/themes_handler/query.go b/backend/handlers/themes_handler/query.go new file mode 100644 index 0000000..14a99f4 --- /dev/null +++ b/backend/handlers/themes_handler/query.go @@ -0,0 +1,228 @@ +package themes_handler + +import ( + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "autolibrary.top/webserver/backend/models/themes_model" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) GetFeaturedThemes(c *gin.Context) { + limitStr := c.Query("limit") + limit := 6 + if limitStr != "" { + if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 20 { + limit = v + } + } + + var themes []themes_model.Theme + result := h.DB. + Where("status = ?", themes_model.ThemeApproved). + Order("download_count DESC"). + Limit(limit). + Find(&themes) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get featured themes: %v", result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get featured themes", + "data": nil, + }) + return + } + + for i := range themes { + themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author) + themes[i].Verified = h.Users.IsVerified(themes[i].UserID) + } + + log.Printf("[I] GET > get featured themes (limit=%d) successful", limit) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get featured themes successful", + "data": themes, + }) +} + +// GET /api/themes?page=1&per_page=9&need_theme=&tags=&search=&sort=popular + +func (h *Handler) GetThemes(c *gin.Context) { + pageStr := c.Query("page") + perPageStr := c.Query("per_page") + needTheme := c.Query("need_theme") + tagsStr := c.Query("tags") + search := c.Query("search") + sort := c.Query("sort") + + if pageStr == "" { + pageStr = "1" + } + if perPageStr == "" { + perPageStr = "9" + } + if sort == "" { + sort = "popular" + } + + page, err := strconv.Atoi(pageStr) + if err != nil || page <= 0 { + page = 1 + } + perPage, err := strconv.Atoi(perPageStr) + if err != nil || perPage <= 0 || perPage > 50 { + perPage = 9 + } + + offset := (page - 1) * perPage + + query := h.DB.Where("status = ?", themes_model.ThemeApproved) + + if needTheme != "" { + if needTheme == "both" { + query = query.Where("need_theme = ?", "both") + } else if needTheme == "dark" || needTheme == "light" { + query = query.Where("need_theme = ? OR need_theme = ?", needTheme, "both") + } + } + + if tagsStr != "" { + tags := strings.Split(tagsStr, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag != "" { + query = query.Where("tags LIKE ?", fmt.Sprintf("%%%s%%", tag)) + } + } + } + + if search != "" { + like := fmt.Sprintf("%%%s%%", search) + query = query.Where("name LIKE ? OR brief LIKE ? OR author LIKE ?", like, like, like) + } + + switch sort { + case "newest": + query = query.Order("created_at DESC") + case "name": + query = query.Order("name ASC") + default: // popular + query = query.Order("download_count DESC") + } + + var total int64 + if result := query.Model(&themes_model.Theme{}).Count(&total); result.Error != nil { + log.Printf("[E] GET > occur an error when count themes: %v", result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get themes", + "data": nil, + }) + return + } + + var themes []themes_model.Theme + result := query.Limit(perPage).Offset(offset).Find(&themes) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get themes (page=%d, per_page=%d): %v", page, perPage, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get themes", + "data": nil, + }) + return + } + + for i := range themes { + themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author) + themes[i].Verified = h.Users.IsVerified(themes[i].UserID) + } + + totalPages := int(total) / perPage + if int(total)%perPage != 0 { + totalPages++ + } + + log.Printf("[I] GET > get themes (page=%d, per_page=%d, total=%d) successful", page, perPage, total) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get themes successful", + "data": gin.H{ + "themes": themes, + "meta": gin.H{ + "page": page, + "per_page": perPage, + "total": total, + "total_pages": totalPages, + "has_next": page < totalPages, + }, + }, + }) +} + +// GET /api/themes/:id + +func (h *Handler) GetThemeByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] GET > try to get theme, but got invalid id: '%s'", c.Param("id")) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > invalid id", + "data": nil, + }) + return + } + + var theme themes_model.Theme + result := h.DB.Where("status = ?", themes_model.ThemeApproved).First(&theme, id) + if result.Error != nil { + log.Printf("[E] GET > theme (id=%d) can not found: %v", id, result.Error) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "GET > theme not found", + "data": nil, + }) + return + } + + theme.Author = h.Users.ResolveAuthor(theme.UserID, theme.Author) + theme.Verified = h.Users.IsVerified(theme.UserID) + + log.Printf("[I] GET > get theme (id=%d) successful", id) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get theme successful", + "data": theme, + }) +} + +// GET /api/themes/:id/download + +func (h *Handler) GetUserThemes(c *gin.Context) { + userID, _ := c.Get("user_id") + + var themes []themes_model.Theme + result := h.DB. + Where("user_id = ?", userID.(string)). + Order("created_at DESC"). + Find(&themes) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get user themes: %v", result.Error) + c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "获取用户主题失败", "data": nil}) + return + } + + for i := range themes { + themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author) + themes[i].Verified = h.Users.IsVerified(themes[i].UserID) + } + + log.Printf("[I] GET > get user themes (user_id=%s, count=%d) successful", userID.(string), len(themes)) + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "GET > get user themes successful", "data": themes}) +} diff --git a/backend/handlers/themes_handler/review.go b/backend/handlers/themes_handler/review.go new file mode 100644 index 0000000..dbf6fff --- /dev/null +++ b/backend/handlers/themes_handler/review.go @@ -0,0 +1,121 @@ +package themes_handler + +import ( + "log" + "net/http" + "strconv" + + "autolibrary.top/webserver/backend/models/themes_model" + "autolibrary.top/webserver/utils" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) GetPendingThemes(c *gin.Context) { + var themes []themes_model.Theme + result := h.DB. + Where("status = ?", themes_model.ThemePending). + Order("created_at ASC"). + Find(&themes) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get pending themes: %v", result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get pending themes", + "data": nil, + }) + return + } + + for i := range themes { + themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author) + themes[i].Verified = h.Users.IsVerified(themes[i].UserID) + } + + log.Printf("[I] GET > get pending themes (count=%d) successful", len(themes)) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get pending themes successful", + "data": themes, + }) +} + +// PUT /api/admin/themes/:id/review + +func (h *Handler) ReviewTheme(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] PUT > try to review theme, but got invalid id: '%s'", c.Param("id")) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > invalid id", + "data": nil, + }) + return + } + + var body struct { + Action string `json:"action"` + Comment string `json:"comment"` + } + if err := c.ShouldBindJSON(&body); err != nil { + log.Printf("[E] PUT > try to review theme (id=%d), but got invalid request data: %v", id, err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "PUT > invalid request body", + "data": nil, + }) + return + } + + if body.Action != "approved" && body.Action != "rejected" { + log.Printf("[E] PUT > try to review theme (id=%d), but got invalid action: '%s'", id, body.Action) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "操作必须为通过或退回", + "data": nil, + }) + return + } + + var theme themes_model.Theme + result := h.DB.First(&theme, id) + if result.Error != nil { + log.Printf("[E] PUT > theme (id=%d) can not found for review: %v", id, result.Error) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "GET > theme not found", + "data": nil, + }) + return + } + + theme.Status = themes_model.ThemeStatus(body.Action) + theme.ReviewComment = body.Comment + theme.ReviewedBy = c.GetString("user_name") + if theme.ReviewedBy == "" { + theme.ReviewedBy = "api-key" + } + theme.UpdatedAt = utils.NowInShanghai() + + result = h.DB.Save(&theme) + if result.Error != nil { + log.Printf("[E] PUT > occur an error when review theme (id=%d): %v", id, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "审核操作失败", + "data": nil, + }) + return + } + + theme.Author = h.Users.ResolveAuthor(theme.UserID, theme.Author) + theme.Verified = h.Users.IsVerified(theme.UserID) + + log.Printf("[I] PUT > review theme (id=%d, action=%s) successful", id, body.Action) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "PUT > review theme successful", + "data": theme, + }) +} diff --git a/backend/handlers/themes_handler/update.go b/backend/handlers/themes_handler/update.go new file mode 100644 index 0000000..655b254 --- /dev/null +++ b/backend/handlers/themes_handler/update.go @@ -0,0 +1,100 @@ +package themes_handler + +import ( + "strconv" + "strings" + "unicode/utf8" + + "autolibrary.top/webserver/backend/models/themes_model" + "autolibrary.top/webserver/utils" + + "github.com/gin-gonic/gin" +) + +func parseThemeID(c *gin.Context) (uint, error) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil || id == 0 { + return 0, strconv.ErrSyntax + } + return uint(id), nil +} + +func (h *Handler) PutThemeByID(c *gin.Context) { + theme, ok := h.ownTheme(c) + if !ok { + return + } + var update struct { + Name string `json:"name"` + Brief string `json:"brief"` + Description string `json:"description"` + NeedTheme string `json:"need_theme"` + Tags []string `json:"tags"` + Version string `json:"version"` + CompatVersion string `json:"compat_version"` + PreviewURL string `json:"preview_url"` + } + if err := c.ShouldBindJSON(&update); err != nil { + fail(c, 400, "Invalid theme metadata") + return + } + if update.Name != "" && strings.TrimSpace(update.Name) != theme.Name { + var count int64 + if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ? AND id != ?", theme.UserID, strings.TrimSpace(update.Name), theme.ID).Count(&count).Error; err != nil { + fail(c, 500, "Unable to check theme name") + return + } + if count > 0 { + fail(c, 409, "You already have a theme with this name") + return + } + theme.Name = strings.TrimSpace(update.Name) + } + if update.NeedTheme != "" && update.NeedTheme != "dark" && update.NeedTheme != "light" && update.NeedTheme != "both" { + fail(c, 400, "Invalid theme mode") + return + } + if utf8.RuneCountInString(update.Brief) > 50 || utf8.RuneCountInString(update.Description) > 100 { + fail(c, 400, "Theme description is too long") + return + } + if strings.TrimSpace(update.Brief) != "" { + theme.Brief = strings.TrimSpace(update.Brief) + } + theme.Description = update.Description + if update.NeedTheme != "" { + theme.NeedTheme = update.NeedTheme + } + if update.Tags != nil { + theme.Tags = update.Tags + } + if update.Version != "" { + theme.Version = update.Version + } + theme.CompatVersion = update.CompatVersion + // Preview changes go through the authenticated image upload endpoint. Preserve + // the legacy field only when it refers to the theme's existing preview. + if update.PreviewURL != "" && update.PreviewURL != theme.PreviewURL { + fail(c, 400, "Use the preview upload endpoint to replace an image") + return + } + theme.Status = themes_model.ThemePending + theme.UpdatedAt = utils.NowInShanghai() + if err := h.Themes.Save(theme, nil); err != nil { + fail(c, 500, "Unable to save theme and archive") + return + } + success(c, 200, "Theme updated; review required", theme) +} + +func (h *Handler) DeleteThemeByID(c *gin.Context) { + theme, ok := h.ownTheme(c) + if !ok { + return + } + if err := h.Themes.Delete(theme); err != nil { + fail(c, 500, "Unable to delete theme") + return + } + success(c, 200, "Theme deleted", nil) +} diff --git a/backend/handlers/themes_handler/upload.go b/backend/handlers/themes_handler/upload.go new file mode 100644 index 0000000..4d9fd31 --- /dev/null +++ b/backend/handlers/themes_handler/upload.go @@ -0,0 +1,223 @@ +package themes_handler + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "unicode/utf8" + + "autolibrary.top/webserver/backend/models/themes_model" + "autolibrary.top/webserver/backend/services/theme_files" + "autolibrary.top/webserver/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func fail(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{"code": status, "msg": message, "data": nil}) +} +func success(c *gin.Context, status int, message string, data interface{}) { + c.JSON(status, gin.H{"code": status, "msg": message, "data": data}) +} + +func (h *Handler) ownTheme(c *gin.Context) (*themes_model.Theme, bool) { + var theme themes_model.Theme + id, err := parseThemeID(c) + if err != nil { + fail(c, http.StatusBadRequest, "Invalid theme ID") + return nil, false + } + err = h.DB.First(&theme, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + fail(c, http.StatusNotFound, "Theme not found") + return nil, false + } + if err != nil { + fail(c, http.StatusInternalServerError, "Unable to load theme") + return nil, false + } + if theme.UserID != c.GetString("user_id") { + fail(c, http.StatusForbidden, "You can only edit your own themes") + return nil, false + } + return &theme, true +} + +// readUpload enforces the same limits advertised by the web form. +func readUpload(c *gin.Context, field string, extensions []string, max int64) ([]byte, error) { + file, header, err := c.Request.FormFile(field) + if err != nil { + return nil, err + } + defer file.Close() + ext := strings.ToLower(filepath.Ext(header.Filename)) + valid := false + for _, candidate := range extensions { + if ext == candidate { + valid = true + } + } + if !valid { + return nil, fmt.Errorf("invalid %s file type", field) + } + if header.Size > max { + return nil, fmt.Errorf("%s file is too large", field) + } + data, err := io.ReadAll(io.LimitReader(file, max+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > max { + return nil, fmt.Errorf("%s file is too large", field) + } + if len(data) == 0 { + return nil, fmt.Errorf("%s file is empty", field) + } + return data, nil +} + +func (h *Handler) savePreview(data []byte) (string, error) { + compressed, err := theme_files.CompressPreviewImage(data) + if err != nil { + return "", err + } + name := fmt.Sprintf("preview_%d.jpg", utils.NowInShanghai().UnixNano()) + if err := h.Files.WriteFile(filepath.Join(h.Files.Root(), "previews", name), compressed, 0644); err != nil { + return "", err + } + return "/marketplace/themes/preview/" + name, nil +} + +func (h *Handler) PostTheme(c *gin.Context) { + var metadata struct { + Name string `json:"name"` + Brief string `json:"brief"` + Description string `json:"description"` + NeedTheme string `json:"need_theme"` + Tags []string `json:"tags"` + Version string `json:"version"` + CompatVersion string `json:"compat_version"` + } + if err := json.Unmarshal([]byte(c.PostForm("metadata")), &metadata); err != nil { + fail(c, 400, "Invalid theme metadata") + return + } + metadata.Name = strings.TrimSpace(metadata.Name) + metadata.Brief = strings.TrimSpace(metadata.Brief) + if metadata.Name == "" || metadata.Brief == "" || utf8.RuneCountInString(metadata.Brief) > 50 || utf8.RuneCountInString(metadata.Description) > 100 { + fail(c, 400, "Invalid name, brief or description") + return + } + if metadata.NeedTheme != "dark" && metadata.NeedTheme != "light" && metadata.NeedTheme != "both" { + fail(c, 400, "Invalid theme mode") + return + } + var count int64 + if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ?", c.GetString("user_id"), metadata.Name).Count(&count).Error; err != nil { + fail(c, 500, "Unable to check theme name") + return + } + if count > 0 { + fail(c, 409, "You already have a theme with this name") + return + } + qss, err := readUpload(c, "file", []string{".qss"}, 8<<20) + if err != nil { + fail(c, 400, err.Error()) + return + } + previewData, err := readUpload(c, "preview", []string{".png", ".jpg", ".jpeg"}, 4<<20) + if err != nil && !errors.Is(err, http.ErrMissingFile) { + fail(c, 400, err.Error()) + return + } + var preview string + if len(previewData) > 0 { + preview, err = h.savePreview(previewData) + if err != nil { + fail(c, 400, "Unable to process preview image") + return + } + } + author := c.GetString("display_name") + if author == "" { + author = c.GetString("user_name") + } + theme := themes_model.Theme{Name: metadata.Name, Brief: metadata.Brief, Description: metadata.Description, NeedTheme: metadata.NeedTheme, Tags: metadata.Tags, Version: metadata.Version, CompatVersion: metadata.CompatVersion, UserID: c.GetString("user_id"), Author: author, Status: themes_model.ThemePending, PreviewURL: preview, CreatedAt: utils.NowInShanghai(), UpdatedAt: utils.NowInShanghai()} + if err := h.Themes.Create(&theme, qss); err != nil { + if preview != "" { + _ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(preview))) + } + fail(c, 500, "Unable to save theme and archive") + return + } + success(c, http.StatusCreated, "Theme submitted for review", theme) +} + +func (h *Handler) CheckThemeName(c *gin.Context) { + name := strings.TrimSpace(c.Query("name")) + if name == "" { + fail(c, 400, "Name is required") + return + } + var count int64 + if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ?", c.GetString("user_id"), name).Count(&count).Error; err != nil { + fail(c, 500, "Unable to check theme name") + return + } + success(c, 200, "Theme name checked", count > 0) +} + +func (h *Handler) UploadPreview(c *gin.Context) { + theme, ok := h.ownTheme(c) + if !ok { + return + } + data, err := readUpload(c, "preview", []string{".png", ".jpg", ".jpeg"}, 4<<20) + if err != nil { + fail(c, 400, err.Error()) + return + } + preview, err := h.savePreview(data) + if err != nil { + fail(c, 400, "Unable to process preview image") + return + } + previous := theme.PreviewURL + theme.PreviewURL = preview + theme.Status = themes_model.ThemePending + theme.UpdatedAt = utils.NowInShanghai() + if err := h.Themes.Save(theme, nil); err != nil { + _ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(preview))) + fail(c, 500, "Unable to save preview") + return + } + if previous != "" { + _ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(previous))) + } + success(c, 200, "Preview updated; review required", gin.H{"preview_url": preview}) +} + +func (h *Handler) ReplaceThemeFile(c *gin.Context) { + theme, ok := h.ownTheme(c) + if !ok { + return + } + qss, err := readUpload(c, "file", []string{".qss"}, 8<<20) + if err != nil { + fail(c, 400, err.Error()) + return + } + theme.Status = themes_model.ThemePending + theme.UpdatedAt = utils.NowInShanghai() + if err := h.Themes.Save(theme, qss); err != nil { + fail(c, 500, "Unable to replace theme file") + return + } + success(c, 200, "File replaced; review required", theme) +} diff --git a/backend/handlers/versions_handler/handler.go b/backend/handlers/versions_handler/handler.go new file mode 100644 index 0000000..a8cd8b8 --- /dev/null +++ b/backend/handlers/versions_handler/handler.go @@ -0,0 +1,301 @@ +package versions_handler + +import ( + "errors" + "gorm.io/gorm" + "log" + "net/http" + "strconv" + + "autolibrary.top/webserver/backend/database/versions_database" + "autolibrary.top/webserver/backend/models/versions_model" + "autolibrary.top/webserver/utils" + + "github.com/gin-gonic/gin" +) + +/* + These handlers are used to manage download versions. +*/ + +// GetLatestDownload returns the newest published stable release, or null. +func GetLatestDownload(c *gin.Context) { + var version versions_model.DownloadVersion + err := versions_database.DB.Where("type = ? AND release_date <= ?", "stable", utils.NowInShanghai()).Order("release_date DESC, id DESC").First(&version).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "No stable release available", "data": nil}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "Unable to load latest release", "data": nil}) + return + } + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "Latest stable release", "data": version}) +} + +// GET /download/versions?page={number}&per_page={number<=50} +func GetDownloadVersions(c *gin.Context) { + // Parse page and perPage parameters + pageStr := c.Query("page") + perPageStr := c.Query("per_page") + + // Default values for page and perPage + if pageStr == "" { + pageStr = "1" + } + if perPageStr == "" { + perPageStr = "9" + } + + page, err := strconv.Atoi(pageStr) + if err != nil { + log.Printf("[E] GET > try to get downloads, but got invalid parameter: 'page=%s'", pageStr) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > try to get downloads, but got invalid parameter: 'page'", + "data": nil, + }) + return + } + perPage, err := strconv.Atoi(perPageStr) + if err != nil { + log.Printf("[E] GET > try to get downloads (page=%d), but got invalid parameter: 'per_page=%s'", page, perPageStr) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > try to get downloads, but got invalid parameter: 'per_page'", + "data": nil, + }) + return + } + + // constrain page and perPage parameters + if page <= 0 { + page = 1 + } + if perPage <= 0 || perPage > 50 { + perPage = 9 + } + + // Calculate offset + offset := (page - 1) * perPage + + var downloads []versions_model.DownloadVersion + result := versions_database.DB.Order("id DESC").Limit(perPage).Offset(offset).Find(&downloads) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get downloads (page=%d, per_page=%d): %v", page, perPage, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get downloads", + "data": nil, + }) + return + } + + var total int64 + result = versions_database.DB.Model(&versions_model.DownloadVersion{}).Count(&total) + if result.Error != nil { + log.Printf("[E] GET > occur an error when get downloads (page=%d, per_page=%d): %v", page, perPage, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "GET > occur an error when get downloads", + "data": nil, + }) + return + } + totalPages := int(total) / perPage + if int(total)%perPage != 0 { + totalPages++ + } + pageMeta := gin.H{ + "page": page, + "per_page": perPage, + "total": total, + "total_pages": totalPages, + "has_next": page < totalPages, + } + + log.Printf("[I] GET > get downloads (page=%d, per_page=%d) successful", page, perPage) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get downloads successful", + "data": gin.H{ + "downloads": downloads, + "meta": pageMeta, + }, + }) +} + +// GET /download/versions/:id +func GetDownloadVersionByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] GET > try to get download, but got invalid id: '%s'", c.Param("id")) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "GET > try to get download, but got invalid id", + "data": nil, + }) + return + } + + var download versions_model.DownloadVersion + result := versions_database.DB.First(&download, id) + if result.Error != nil { + log.Printf("[E] GET > download (id=%d) can not found: %v", id, result.Error) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "GET > download can not found", + "data": nil, + }) + return + } + + log.Printf("[I] GET > get download (id=%d) successful", id) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "GET > get download successful", + "data": download, + }) +} + +// POST /download/versions +func PostDownloadVersion(c *gin.Context) { + var download versions_model.DownloadVersion + if err := c.ShouldBindJSON(&download); err != nil { + log.Printf("[E] POST > try to create download, but got invalid request data: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "POST > try to create download, but got invalid request data", + "data": nil, + }) + return + } + + download.CreatedAt = utils.NowInShanghai() + result := versions_database.DB.Create(&download) + if result.Error != nil { + log.Printf("[E] POST > occur an error when create download: %v", result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "POST > occur an error when create download", + "data": nil, + }) + return + } + + id := strconv.FormatUint(uint64(download.ID), 10) + log.Printf("[I] POST > create download (id=%s) successful", id) + c.JSON(http.StatusCreated, gin.H{ + "code": http.StatusCreated, + "msg": "POST > create download successful", + "data": download, + }) +} + +// PUT /download/versions/:id +func PutDownloadVersionByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] PUT > try to update download, but got invalid id: '%s'", c.Param("id")) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "PUT > try to update download, but got invalid id", + "data": nil, + }) + return + } + + var updateDownload versions_model.DownloadVersion + if err = c.ShouldBindJSON(&updateDownload); err != nil { + log.Printf("[E] PUT > try to update download (id=%d), but got invalid request data: %v", id, err) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "PUT > try to update download, but got invalid request data", + "data": nil, + }) + return + } + + var existingDownload versions_model.DownloadVersion + result := versions_database.DB.First(&existingDownload, id) + if result.Error != nil { + log.Printf("[E] PUT > download (id=%d) can not found: %v", id, result.Error) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "PUT > download can not found", + "data": nil, + }) + return + } + + result = versions_database.DB.Model(&versions_model.DownloadVersion{}).Where("id = ?", id).Updates(updateDownload) + if result.Error != nil { + log.Printf("[E] PUT > occur an error when update download (id=%d): %v", id, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "PUT > occur an error when update download", + "data": nil, + }) + return + } + + // post check after update + var updatedDownload versions_model.DownloadVersion + if result = versions_database.DB.First(&updatedDownload, id); result.Error == nil { + log.Printf("[I] PUT > update download (id=%d) successful", id) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "PUT > update download successful", + "data": updatedDownload, + }) + } else { + log.Printf("[W] PUT > update successful but failed to retrieve updated download (id=%d): %v", id, result.Error) + c.JSON(http.StatusAccepted, gin.H{ + "code": http.StatusAccepted, + "msg": "PUT > update successful but failed to retrieve updated download", + "data": nil, + }) + } +} + +// DELETE /download/versions/:id +func DeleteDownloadVersionByID(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + log.Printf("[E] DELETE > try to delete download, but got invalid id: %s", c.Param("id")) + c.JSON(http.StatusBadRequest, gin.H{ + "code": http.StatusBadRequest, + "msg": "DELETE > try to delete download, but got invalid id", + "data": nil, + }) + return + } + + result := versions_database.DB.Delete(&versions_model.DownloadVersion{}, id) + if result.Error != nil { + log.Printf("[E] DELETE > occur an error when delete download (id=%d): %v", id, result.Error) + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "DELETE > occur an error when delete download", + "data": nil, + }) + return + } + + if result.RowsAffected == 0 { + log.Printf("[E] DELETE > try to delete download (id=%d), but RowsAffected is 0", id) + c.JSON(http.StatusNotFound, gin.H{ + "code": http.StatusNotFound, + "msg": "DELETE > delete download has no effect", + "data": nil, + }) + return + } + + log.Printf("[I] DELETE > delete download (id=%d) successful", id) + c.JSON(http.StatusOK, gin.H{ + "code": http.StatusOK, + "msg": "DELETE > delete download successful", + "data": nil, + }) +} diff --git a/backend/middlewares/all.go b/backend/middlewares/all.go new file mode 100644 index 0000000..ecc73ee --- /dev/null +++ b/backend/middlewares/all.go @@ -0,0 +1,6 @@ +package middlewares + +import ( + _ "autolibrary.top/webserver/backend/middlewares/auth_middleware" + _ "autolibrary.top/webserver/backend/middlewares/casdoor_middleware" +) diff --git a/backend/middlewares/auth_middleware/middleware.go b/backend/middlewares/auth_middleware/middleware.go new file mode 100644 index 0000000..9f2a843 --- /dev/null +++ b/backend/middlewares/auth_middleware/middleware.go @@ -0,0 +1,49 @@ +package auth_middleware + +import ( + "log" + "net/http" + + "autolibrary.top/webserver/config" + + "github.com/gin-gonic/gin" +) + +func APIKeyAuth() gin.HandlerFunc { + return func(c *gin.Context) { + cfg := config.GetConfig() + if !cfg.Backend.Auth.Required { + c.Next() + return + } + + apiKey := c.GetHeader("X-API-Key") + if apiKey == "" { + apiKey = c.Query("api_key") + } + + // Check if API key is configured + expectedKey := cfg.Backend.Auth.Key + if expectedKey == "" || expectedKey == "null" { + log.Printf("[E] AUTH > API key not configured") + c.JSON(http.StatusInternalServerError, gin.H{ + "code": http.StatusInternalServerError, + "msg": "AUTH > API key has not been configured on this server", + }) + c.Abort() + return + } + + // Check if API key is valid + if apiKey == "" || apiKey != expectedKey { + log.Printf("[W] AUTH > invalid API key or key is missing") + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "msg": "AUTH > invalid API key or key is missing", + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/middlewares/casdoor_middleware/middleware.go b/backend/middlewares/casdoor_middleware/middleware.go new file mode 100644 index 0000000..b2cf24a --- /dev/null +++ b/backend/middlewares/casdoor_middleware/middleware.go @@ -0,0 +1,92 @@ +package casdoor_middleware + +import ( + "log" + "net/http" + "strings" + + "autolibrary.top/webserver/config" + + "github.com/casdoor/casdoor-go-sdk/casdoorsdk" + "github.com/gin-gonic/gin" +) + +func CasdoorAuth() gin.HandlerFunc { + cfg := config.GetConfig() + + return func(c *gin.Context) { + // If Casdoor is not configured, return 501 Not Implemented + if cfg.Backend.Casdoor.Endpoint == "" || cfg.Backend.Casdoor.ClientID == "" { + c.JSON(http.StatusNotImplemented, gin.H{ + "code": http.StatusNotImplemented, + "msg": "AUTH > Casdoor authentication is not configured on this server", + "data": nil, + }) + c.Abort() + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "msg": "AUTH > missing Authorization header", + "data": nil, + }) + c.Abort() + return + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == authHeader { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "msg": "AUTH > Authorization header must be Bearer ", + "data": nil, + }) + c.Abort() + return + } + + claims, err := casdoorsdk.ParseJwtToken(token) + if err != nil { + log.Printf("[W] AUTH > invalid JWT token: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "msg": "AUTH > invalid or expired token", + "data": nil, + }) + c.Abort() + return + } + + c.Set("user_id", claims.User.Id) + c.Set("user_name", claims.User.Name) + c.Set("display_name", claims.User.DisplayName) + c.Set("is_admin", claims.User.IsAdmin) + c.Set("user_tag", claims.User.Tag) + c.Next() + } +} + +// CasdoorAdminAuth requires both valid Casdoor JWT and admin privileges +func CasdoorAdminAuth() gin.HandlerFunc { + auth := CasdoorAuth() + return func(c *gin.Context) { + auth(c) + if c.IsAborted() { + return + } + isAdmin, _ := c.Get("is_admin") + if isAdmin == nil || !isAdmin.(bool) { + c.JSON(http.StatusForbidden, gin.H{ + "code": http.StatusForbidden, + "msg": "AUTH > admin privileges required", + "data": nil, + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/models/all.go b/backend/models/all.go new file mode 100644 index 0000000..8082e6d --- /dev/null +++ b/backend/models/all.go @@ -0,0 +1,8 @@ +package models + +import ( + _ "autolibrary.top/webserver/backend/models/base_model" + _ "autolibrary.top/webserver/backend/models/versions_model" + _ "autolibrary.top/webserver/backend/models/bulletins_model" + _ "autolibrary.top/webserver/backend/models/themes_model" +) \ No newline at end of file diff --git a/backend/models/base_model/model.go b/backend/models/base_model/model.go new file mode 100644 index 0000000..33d2f1b --- /dev/null +++ b/backend/models/base_model/model.go @@ -0,0 +1,45 @@ +package base_model + +import ( + "fmt" + "encoding/json" + "database/sql/driver" +) + +type StringArray []string + +func (s StringArray) Value() (driver.Value, error) { + if len(s) == 0 { + return "[]", nil // empty array + } + return json.Marshal(s) +} + +func (s *StringArray) Scan(value interface{}) error { + if value == nil { + *s = StringArray{} + return nil + } + + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("unsupported type: %T", value) + } + + if len(bytes) == 0 { + *s = StringArray{} + return nil + } + + if string(bytes) == "[]" { + *s = StringArray{} + return nil + } + + return json.Unmarshal(bytes, s) +} \ No newline at end of file diff --git a/backend/models/bulletins_model/model.go b/backend/models/bulletins_model/model.go new file mode 100644 index 0000000..65b7346 --- /dev/null +++ b/backend/models/bulletins_model/model.go @@ -0,0 +1,18 @@ +package bulletins_model + +import ( + "time" +) + +type Bulletin struct { + ID uint `json:"id" gorm:"primaryKey"` + DateTime time.Time `json:"dateTime" gorm:"index"` // Time zone: Asia/Shanghai + Author string `json:"author" gorm:"type:varchar(20);index"` + Title string `json:"title"` + Content string `json:"content"` + // we use soft delete, to keep the uniqueness of ID + LastEditAt time.Time `json:"lastEditAt"` // Time zone: Asia/Shanghai + IsEdited bool `json:"isEdited" gorm:"default:false"` + IsDeleted bool `json:"isDeleted" gorm:"default:false"` + CreateAt time.Time `json:"createAt"` // Time zone: Asia/Shanghai +} diff --git a/backend/models/themes_model/model.go b/backend/models/themes_model/model.go new file mode 100644 index 0000000..605ce04 --- /dev/null +++ b/backend/models/themes_model/model.go @@ -0,0 +1,38 @@ +package themes_model + +import ( + "time" + + "autolibrary.top/webserver/backend/models/base_model" +) + +type ThemeStatus string + +const ( + ThemePending ThemeStatus = "pending" + ThemeApproved ThemeStatus = "approved" + ThemeRejected ThemeStatus = "rejected" +) + +type Theme struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name"` + Author string `json:"author"` + UserID string `json:"userId" gorm:"index"` + NeedTheme string `json:"needTheme" gorm:"type:varchar(10);index"` + Brief string `json:"brief"` + Description string `json:"description"` + Tags base_model.StringArray `json:"tags" gorm:"type:text"` + Status ThemeStatus `json:"status" gorm:"type:varchar(20);index;default:pending"` + FilePath string `json:"-" gorm:"type:text"` + FileSize string `json:"fileSize"` + PreviewURL string `json:"previewUrl"` + DownloadCount uint `json:"downloadCount" gorm:"default:0"` + Version string `json:"version"` + CompatVersion string `json:"compatVersion"` + ReviewedBy string `json:"reviewedBy"` + ReviewComment string `json:"reviewComment"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Verified bool `json:"verified" gorm:"-"` +} diff --git a/backend/models/versions_model/model.go b/backend/models/versions_model/model.go new file mode 100644 index 0000000..87cc266 --- /dev/null +++ b/backend/models/versions_model/model.go @@ -0,0 +1,29 @@ +package versions_model + +import ( + "time" +) + +type VersionType string + +type DownloadVersion struct { + ID uint `json:"id" gorm:"primaryKey"` + Version string `json:"version"` + Type VersionType `json:"type" gorm:"type:varchar(20);index"` + ReleaseDate time.Time `json:"releaseDate"` // Time zone: Asia/Shanghai + FileSize string `json:"fileSize"` + SupportOS string `json:"supportOS"` + Features string `json:"features"` // separated by ',' + GitHubZipURL string `json:"githubZipUrl"` + GitHubInstallerURL string `json:"githubInstallerUrl"` + CloudZipURL string `json:"cloudZipUrl"` + CloudInstallerURL string `json:"cloudInstallerUrl"` + GitHubDmgURL string `json:"githubDmgUrl"` + GiteaZipURL string `json:"giteaZipUrl"` + GiteaInstallerURL string `json:"giteaInstallerUrl"` + GiteaDmgURL string `json:"giteaDmgUrl"` + ReleaseNotesURL string `json:"releaseNotesUrl"` + CloudDmgURL string `json:"cloudDmgUrl"` + CloudPassword string `json:"cloudPassword"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/services/theme_files/archive.go b/backend/services/theme_files/archive.go new file mode 100644 index 0000000..776b123 --- /dev/null +++ b/backend/services/theme_files/archive.go @@ -0,0 +1,186 @@ +package theme_files + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "image" + "image/jpeg" + _ "image/png" + "io" + "log" + "os" + + "autolibrary.top/webserver/backend/models/themes_model" +) + +func CompressPreviewImage(data []byte) ([]byte, error) { + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > 40_000_000 { + return nil, fmt.Errorf("invalid image or image dimensions exceed the limit") + } + img, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("cannot decode image: %v", err) + } + + // Resize if width exceeds 800px + bounds := img.Bounds() + w := bounds.Dx() + h := bounds.Dy() + maxW := 800 + if w > maxW { + h = h * maxW / w + if h < 1 { + h = 1 + } + w = maxW + // Simple resize using standard library + scaled := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + srcX := x * bounds.Dx() / w + srcY := y * bounds.Dy() / h + scaled.Set(x, y, img.At(srcX, srcY)) + } + } + img = scaled + } + + var buf bytes.Buffer + err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}) + if err != nil { + return nil, fmt.Errorf("cannot encode JPEG: %v", err) + } + + log.Printf("[I] image compressed: %s %dx%d, %d -> %d bytes", format, bounds.Dx(), bounds.Dy(), len(data), buf.Len()) + return buf.Bytes(), nil +} + +// GET /api/themes/featured?limit=6 + +func ValidateAlthemeFile(data []byte) (map[string]interface{}, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("invalid zip file: %v", err) + } + + var hasInfoJSON, hasThemeQSS bool + var info map[string]interface{} + + for _, f := range reader.File { + if f.Name == "info.json" { + hasInfoJSON = true + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("cannot open info.json: %v", err) + } + decoder := json.NewDecoder(rc) + if err := decoder.Decode(&info); err != nil { + rc.Close() + return nil, fmt.Errorf("invalid info.json: %v", err) + } + rc.Close() + } + if f.Name == "theme.qss" { + hasThemeQSS = true + } + } + + if !hasInfoJSON { + return nil, fmt.Errorf("missing info.json in .altheme file") + } + if !hasThemeQSS { + return nil, fmt.Errorf("missing theme.qss in .altheme file") + } + + required := []string{"name", "author", "need_theme", "brief"} + for _, key := range required { + if _, ok := info[key]; !ok { + return nil, fmt.Errorf("info.json missing required key: %s", key) + } + } + + return info, nil +} + +// buildInfoJSON constructs the info.json map from a Theme DB record. +// It merges Brief and Description with a newline separator into the "brief" field. + +func BuildInfoJSON(theme themes_model.Theme) map[string]interface{} { + brief := theme.Brief + if theme.Description != "" { + brief = brief + "\n" + theme.Description + } + return map[string]interface{}{ + "qss": "theme.qss", + "name": theme.Name, + "author": theme.Author, + "need_theme": theme.NeedTheme, + "brief": brief, + "version": theme.Version, + "compatible_version": theme.CompatVersion, + } +} + +// createAlthemeArchive creates a .altheme ZIP archive in memory +// containing theme.qss (from qssData) and info.json (from info map). + +func CreateAlthemeArchive(qssData []byte, info map[string]interface{}) ([]byte, error) { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + // Add theme.qss + qssWriter, err := w.Create("theme.qss") + if err != nil { + return nil, fmt.Errorf("cannot create theme.qss in archive: %v", err) + } + if _, err := qssWriter.Write(qssData); err != nil { + return nil, fmt.Errorf("cannot write theme.qss to archive: %v", err) + } + + // Add info.json + infoWriter, err := w.Create("info.json") + if err != nil { + return nil, fmt.Errorf("cannot create info.json in archive: %v", err) + } + infoBytes, err := json.MarshalIndent(info, "", " ") + if err != nil { + return nil, fmt.Errorf("cannot marshal info.json: %v", err) + } + if _, err := infoWriter.Write(infoBytes); err != nil { + return nil, fmt.Errorf("cannot write info.json to archive: %v", err) + } + + if err := w.Close(); err != nil { + return nil, fmt.Errorf("cannot close archive: %v", err) + } + + return buf.Bytes(), nil +} + +// extractQSSFromAltheme reads an existing .altheme ZIP file from disk +// and returns the raw bytes of its theme.qss entry. + +func ExtractQSSFromAltheme(filePath string) ([]byte, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("cannot read .altheme file: %v", err) + } + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("cannot open .altheme as zip: %v", err) + } + for _, f := range reader.File { + if f.Name == "theme.qss" { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("cannot open theme.qss in archive: %v", err) + } + defer rc.Close() + return io.ReadAll(rc) + } + } + return nil, fmt.Errorf("theme.qss not found in .altheme archive") +} diff --git a/backend/services/theme_files/store.go b/backend/services/theme_files/store.go new file mode 100644 index 0000000..d5c8eff --- /dev/null +++ b/backend/services/theme_files/store.go @@ -0,0 +1,69 @@ +package theme_files + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Store owns writes under a single configured storage directory. Replacing an +// archive uses a temporary file so readers never receive partially written ZIPs. +type Store struct{ root string } + +func NewStore(root string) (*Store, error) { + absolute, err := filepath.Abs(root) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Join(absolute, "previews"), 0755); err != nil { + return nil, err + } + return &Store{root: absolute}, nil +} +func (s *Store) Root() string { return s.root } +func (s *Store) checked(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + relative, err := filepath.Rel(s.root, absolute) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return "", fmt.Errorf("file path is outside theme storage") + } + return absolute, nil +} +func (s *Store) WriteFile(path string, data []byte, mode os.FileMode) error { + target, err := s.checked(path) + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(target), ".upload-*") + if err != nil { + return err + } + temp := file.Name() + defer os.Remove(temp) + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + if err := file.Chmod(mode); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(temp, target) +} +func (s *Store) Remove(path string) error { + target, err := s.checked(path) + if err != nil { + return err + } + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/backend/services/themes_service/service.go b/backend/services/themes_service/service.go new file mode 100644 index 0000000..8dbd0b2 --- /dev/null +++ b/backend/services/themes_service/service.go @@ -0,0 +1,117 @@ +package themes_service + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sync" + + "autolibrary.top/webserver/backend/models/themes_model" + "autolibrary.top/webserver/backend/services/theme_files" + + "gorm.io/gorm" +) + +// Service coordinates database changes and their corresponding theme archive. +type Service struct { + DB *gorm.DB + Files *theme_files.Store + locks sync.Map +} + +func (s *Service) lock(id uint) func() { + value, _ := s.locks.LoadOrStore(id, &sync.Mutex{}) + mutex := value.(*sync.Mutex) + mutex.Lock() + return mutex.Unlock +} + +func (s *Service) ArchivePath(id uint) string { + return filepath.Join(s.Files.Root(), fmt.Sprintf("%d.altheme", id)) +} + +func (s *Service) Create(theme *themes_model.Theme, qss []byte) error { + var path string + err := s.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(theme).Error; err != nil { + return err + } + path = s.ArchivePath(theme.ID) + data, err := theme_files.CreateAlthemeArchive(qss, theme_files.BuildInfoJSON(*theme)) + if err != nil { + return err + } + if err := s.Files.WriteFile(path, data, 0644); err != nil { + return err + } + theme.FilePath = path + theme.FileSize = fmt.Sprintf("%.1f KB", float64(len(data))/1024) + return tx.Save(theme).Error + }) + if err != nil && path != "" { + _ = s.Files.Remove(path) + } + return err +} + +func (s *Service) Save(theme *themes_model.Theme, qss []byte) error { + unlock := s.lock(theme.ID) + defer unlock() + path := s.ArchivePath(theme.ID) + previous, readErr := os.ReadFile(path) + if qss == nil { + var err error + qss, err = theme_files.ExtractQSSFromAltheme(path) + if err != nil { + return fmt.Errorf("cannot read existing theme archive: %w", err) + } + } + data, err := theme_files.CreateAlthemeArchive(qss, theme_files.BuildInfoJSON(*theme)) + if err != nil { + return err + } + theme.FilePath = path + theme.FileSize = fmt.Sprintf("%.1f KB", float64(len(data))/1024) + wrote := false + err = s.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(theme).Error; err != nil { + return err + } + if err := s.Files.WriteFile(path, data, 0644); err != nil { + return err + } + wrote = true + return nil + }) + if err != nil && wrote { + var restoreErr error + if readErr == nil { + restoreErr = s.Files.WriteFile(path, previous, 0644) + } else { + restoreErr = s.Files.Remove(path) + } + if restoreErr != nil { + log.Printf("[E] archive rollback failed for theme %d: %v", theme.ID, restoreErr) + } + } + return err +} + +func (s *Service) Delete(theme *themes_model.Theme) error { + unlock := s.lock(theme.ID) + defer unlock() + if err := s.DB.Delete(theme).Error; err != nil { + return err + } + paths := []string{s.ArchivePath(theme.ID)} + if theme.PreviewURL != "" { + paths = append(paths, filepath.Join(s.Files.Root(), "previews", filepath.Base(theme.PreviewURL))) + } + for _, path := range paths { + if err := s.Files.Remove(path); err != nil { + log.Printf("[W] theme %d file cleanup: %v", theme.ID, err) + } + } + return nil +} diff --git a/backend/services/users_service/cache.go b/backend/services/users_service/cache.go new file mode 100644 index 0000000..a3ad7e0 --- /dev/null +++ b/backend/services/users_service/cache.go @@ -0,0 +1,77 @@ +package users_service + +import ( + "log" + "sync" + "time" + + "github.com/casdoor/casdoor-go-sdk/casdoorsdk" +) + +type user struct { + DisplayName string + Verified bool +} + +// Cache atomically replaces snapshots and throttles refreshes, including misses. +// Failed authentication lookups preserve the last usable display-name snapshot. +type Cache struct { + mu sync.RWMutex + refresh sync.Mutex + users map[string]user + updated time.Time + enabled bool +} + +func New(enabled bool) *Cache { return &Cache{users: make(map[string]user), enabled: enabled} } + +func (c *Cache) Refresh() { + if !c.enabled { + return + } + c.refresh.Lock() + defer c.refresh.Unlock() + c.mu.RLock() + recent := time.Since(c.updated) < time.Minute + c.mu.RUnlock() + if recent { + return + } + users, err := casdoorsdk.GetUsers() + c.mu.Lock() + defer c.mu.Unlock() + c.updated = time.Now() + if err != nil { + log.Printf("[W] cannot refresh author cache: %v", err) + return + } + snapshot := make(map[string]user, len(users)) + for _, u := range users { + name := u.DisplayName + if name == "" { + name = u.Name + } + snapshot[u.Id] = user{DisplayName: name, Verified: u.IsAdmin || u.Tag == "verified"} + } + c.users = snapshot +} + +func (c *Cache) lookup(id string) (user, bool) { + c.mu.RLock() + stale := time.Since(c.updated) >= time.Minute + c.mu.RUnlock() + if stale { + c.Refresh() + } + c.mu.RLock() + defer c.mu.RUnlock() + u, ok := c.users[id] + return u, ok +} +func (c *Cache) ResolveAuthor(id, fallback string) string { + if u, ok := c.lookup(id); ok && u.DisplayName != "" { + return u.DisplayName + } + return fallback +} +func (c *Cache) IsVerified(id string) bool { u, ok := c.lookup(id); return ok && u.Verified } diff --git a/backend/templates/404.html b/backend/templates/404.html new file mode 100644 index 0000000..5e98f3d --- /dev/null +++ b/backend/templates/404.html @@ -0,0 +1,118 @@ + + + + + + + + {{.title}} + + + +

404 - Not Found

+

The requested API endpoint '{{.path}}' was not found on this server.

+ +
+ API Response (JSON format): +
+ +
+ {{.jsonData}} +
+ + + + + + \ No newline at end of file diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..8924813 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,25 @@ +package cli + +import ( + "github.com/urfave/cli/v2" +) + +const ( + appName = "webserver" + appVersion = "0.0.2" +) + +func NewApp() *cli.App { + return &cli.App{ + Name: appName, + Version: appVersion, + Usage: "AutoLibrary Web Server CLI", + Commands: []*cli.Command{ + &cmdStartAll, + &cmdFrontend, + &cmdBackend, + &cmdConfig, + }, + Action: nil, + } +} diff --git a/cli/cmd.go b/cli/cmd.go new file mode 100644 index 0000000..2a94a2e --- /dev/null +++ b/cli/cmd.go @@ -0,0 +1,184 @@ +package cli + +import ( + "errors" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "syscall" + + "autolibrary.top/webserver/backend" + "autolibrary.top/webserver/config" + "autolibrary.top/webserver/frontend" + "autolibrary.top/webserver/utils" + + "github.com/urfave/cli/v2" +) + +func getConfigPath(c *cli.Context) string { + rootPath := utils.GetRootPath() + configPath := filepath.Join(rootPath, "config.json") + if c.String("config") != "" { + configPath = c.String("config") + if !filepath.IsAbs(configPath) { + configPath = filepath.Join(rootPath, configPath) + } + } + return configPath +} + +// runServices propagates startup failures and drains every running service. +func runServices(c *cli.Context, startFrontend, startBackend bool) error { + if _, err := config.LoadConfig(getConfigPath(c), !c.IsSet("config")); err != nil { + return err + } + ctx, stop := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) + defer stop() + results := make(chan error, 2) + count := 0 + if startBackend { + count++ + go func() { results <- backend.StartBackendContext(ctx) }() + } + if startFrontend { + count++ + go func() { results <- frontend.StartFrontendContext(ctx) }() + } + var failures []error + for i := 0; i < count; i++ { + err := <-results + if err != nil { + failures = append(failures, err) + } + stop() + } + log.Printf("[I] server shutdown complete") + return errors.Join(failures...) +} + +// These are the commands function for the CLI application +/* + command : webserver backend + + Start backend server +*/ +func commandBackend(c *cli.Context) error { + return runServices(c, false, true) +} + +// command instance for backend +var cmdBackend = cli.Command{ + Name: "backend", + Aliases: []string{"b"}, + Usage: "Start only the backend server", + Action: commandBackend, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Value: "config.json", + Usage: "Load configuration from `FILE`", + }, + }, +} + +/* +command : webserver frontend + +Start frontend server +*/ +func commandFrontend(c *cli.Context) error { + return runServices(c, true, false) +} + +// command instance for frontend +var cmdFrontend = cli.Command{ + Name: "frontend", + Aliases: []string{"f"}, + Usage: "Start only the frontend server", + Action: commandFrontend, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Value: "config.json", + Usage: "Load configuration from `FILE`", + }, + }, +} + +/* +command : webserver start + +Start both backend and frontend servers +*/ +func commandStartAll(c *cli.Context) error { + return runServices(c, true, true) +} + +// command instance for start all +var cmdStartAll = cli.Command{ + Name: "start", + Aliases: []string{"s"}, + Usage: "Start both backend and frontend servers", + Action: commandStartAll, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Value: "config.json", + Usage: "Load configuration from `FILE`", + }, + }, +} + +/* +command : webserver config + +Show current configuration +*/ +func commandConfig(c *cli.Context) error { + configPath := getConfigPath(c) + cfg, err := config.LoadConfig(configPath, !c.IsSet("config")) + if err != nil { + return err + } + + fmt.Printf("Current Configuration (Path: %s):\n", configPath) + fmt.Printf(" Backend: %s:%s\n", cfg.Backend.Host, cfg.Backend.Port) + fmt.Printf(" Auth:\n") + fmt.Printf(" - Required: %v\n", cfg.Backend.Auth.Required) + fmt.Printf(" - Key: %s\n", cfg.Backend.Auth.Key) + fmt.Printf(" Database:\n") + fmt.Printf(" - Versions: %s\n", cfg.Backend.Database.Versions.Path) + fmt.Printf(" - Bulletins: %s\n", cfg.Backend.Database.Bulletins.Path) + fmt.Printf(" - Themes: %s\n", cfg.Backend.Database.Themes.Path) + fmt.Printf(" - Themes Files: %s\n", cfg.Backend.Database.ThemesFiles.Path) + fmt.Printf(" Casdoor:\n") + fmt.Printf(" - Endpoint: %s\n", cfg.Backend.Casdoor.Endpoint) + fmt.Printf(" - Organization: %s\n", cfg.Backend.Casdoor.Organization) + fmt.Printf(" - Application: %s\n", cfg.Backend.Casdoor.Application) + fmt.Printf(" Frontend: %s:%s\n", cfg.Frontend.Host, cfg.Frontend.Port) + fmt.Printf(" Static:\n") + fmt.Printf(" - Path: %s\n", cfg.Frontend.Static.Path) + + return nil +} + +// command instance for config +var cmdConfig = cli.Command{ + Name: "config", + Aliases: []string{"cfg"}, + Usage: "Show current configuration", + Action: commandConfig, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Value: "config.json", + Usage: "Load configuration from `FILE`", + }, + }, +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..f438b1b --- /dev/null +++ b/config.example.json @@ -0,0 +1,26 @@ +{ + "backend": { + "host": "127.0.0.1", + "port": "8080", + "auth": { "required": true, "key": "replace-with-your-api-key" }, + "database": { + "versions": { "path": "./backend/data/versions.db" }, + "bulletins": { "path": "./backend/data/bulletins.db" }, + "themes": { "path": "./backend/data/themes.db" }, + "themes_files": { "path": "./backend/data/themes_files" } + }, + "casdoor": { + "endpoint": "http://127.0.0.1:8000", + "client_id": "", + "client_secret": "", + "certificate": "", + "organization": "autolibrary", + "application": "marketplace" + } + }, + "frontend": { + "host": "127.0.0.1", + "port": "3000", + "static": { "path": "./frontend/dist" } + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ea6d908 --- /dev/null +++ b/config/config.go @@ -0,0 +1,117 @@ +package config + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sync" +) + +type Config struct { + Backend struct { + Port string `json:"port"` + Host string `json:"host"` + Auth struct { + Required bool `json:"required"` + Key string `json:"key"` + } `json:"auth"` + Database struct { + Versions struct { + Path string `json:"path"` + } `json:"versions"` + Bulletins struct { + Path string `json:"path"` + } `json:"bulletins"` + Themes struct { + Path string `json:"path"` + } `json:"themes"` + ThemesFiles struct { + Path string `json:"path"` + } `json:"themes_files"` + } `json:"database"` + Casdoor struct { + Endpoint string `json:"endpoint"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Certificate string `json:"certificate"` + Organization string `json:"organization"` + Application string `json:"application"` + } `json:"casdoor"` + } `json:"backend"` + + Frontend struct { + Port string `json:"port"` + Host string `json:"host"` + Static struct { + Path string `json:"path"` + } `json:"static"` + } `json:"frontend"` +} + +var ( + instance *Config + once sync.Once + loadErr error +) + +func createDefaultConfig() *Config { + config := &Config{} + config.Backend.Port = "8080" + config.Backend.Host = "127.0.0.1" + config.Backend.Auth.Required = false + config.Backend.Auth.Key = "null" + config.Backend.Database.Versions.Path = "./backend/data/versions.db" + config.Backend.Database.Bulletins.Path = "./backend/data/bulletins.db" + config.Backend.Database.Themes.Path = "./backend/data/themes.db" + config.Backend.Database.ThemesFiles.Path = "./backend/data/themes_files" + + config.Backend.Casdoor.Endpoint = "http://127.0.0.1:8000" + config.Backend.Casdoor.ClientID = "" + config.Backend.Casdoor.ClientSecret = "" + config.Backend.Casdoor.Certificate = "" + config.Backend.Casdoor.Organization = "autolibrary" + config.Backend.Casdoor.Application = "marketplace" + + config.Frontend.Port = "3000" + config.Frontend.Host = "127.0.0.1" + config.Frontend.Static.Path = "./frontend/dist" + + return config +} + +func LoadConfig(configPath string, allowDefaults bool) (*Config, error) { + once.Do(func() { + data, err := os.ReadFile(configPath) + if err != nil { + loadErr = err + return + } + cfg := createDefaultConfig() + if err := json.Unmarshal(data, &cfg); err != nil { + loadErr = fmt.Errorf("decode config %q: %w", configPath, err) + return + } + if cfg == nil { + loadErr = fmt.Errorf("config %q must be a JSON object", configPath) + return + } + instance = cfg + }) + if loadErr != nil { + if !allowDefaults { + return nil, loadErr + } + log.Printf("[W] using default config: %v", loadErr) + return createDefaultConfig(), nil + } + log.Printf("[I] config loaded successfully from: %s", configPath) + return instance, nil +} + +func GetConfig() *Config { + if instance == nil { + return createDefaultConfig() + } + return instance +} diff --git a/deploy/autolibrary-webserver.service b/deploy/autolibrary-webserver.service new file mode 100644 index 0000000..e0ab30e --- /dev/null +++ b/deploy/autolibrary-webserver.service @@ -0,0 +1,19 @@ +[Unit] +Description=AutoLibrary Website +After=network.target + +[Service] +Type=exec +User=autolibrary +Group=autolibrary +WorkingDirectory=/opt/autolibrary/current +ExecStart=/opt/autolibrary/current/webserver start --config /opt/autolibrary/shared/config.json +Environment=GIN_MODE=release +UMask=0027 +NoNewPrivileges=true +Restart=on-failure +RestartSec=5s +TimeoutStopSec=20s + +[Install] +WantedBy=multi-user.target diff --git a/deploy/build.sh b/deploy/build.sh new file mode 100644 index 0000000..f641d12 --- /dev/null +++ b/deploy/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +export CGO_ENABLED=1 +go test ./... +mkdir -p .deploy +go build -trimpath -o .deploy/webserver . +npm --prefix frontend ci +npm --prefix frontend run build diff --git a/deploy/publish.sh b/deploy/publish.sh new file mode 100644 index 0000000..b5960b5 --- /dev/null +++ b/deploy/publish.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +root=/opt/autolibrary +service=autolibrary-webserver.service +current=$root/current +sha=${RELEASE_SHA:?Missing RELEASE_SHA} +port=${AUTOLIBRARY_PORT:-3003} +[[ $sha =~ ^[0-9a-f]{40}$ && $(git rev-parse HEAD) == "$sha" ]] +[[ $port =~ ^[1-9][0-9]{0,4}$ && $port -le 65535 ]] +base_url=http://127.0.0.1:$port + +healthy() { + local path + for path in / /api/health; do + [[ $(curl --silent --show-error --noproxy '*' --max-time 2 \ + --output /dev/null --write-out '%{http_code}' "$base_url$path") == 200 ]] || return 1 + done +} + +wait_for_service() { + local attempt + for ((attempt = 0; attempt < 20; attempt++)); do + if systemctl is-active --quiet "$service" && healthy; then + return 0 + fi + sleep 1 + done + return 1 +} + +# Serialize releases, including manual reruns of the workflow. +exec 9>"$root/releases/.deploy.lock" +flock -x 9 +remote_head=$(git ls-remote --exit-code origin refs/heads/main) +if [[ ${remote_head%%$'\t'*} != "$sha" ]]; then + echo "Skipping $sha because main has moved." + exit 0 +fi + +previous= +if [[ -L $current ]]; then + previous=$(readlink "$current") + [[ -d $current ]] || { echo "Broken release link: $current" >&2; exit 1; } +elif [[ -e $current ]]; then + echo "Expected a release symlink at $current" >&2 + exit 1 +fi + +test -f "$root/shared/config.json" +sudo -n -l /usr/bin/systemctl restart "$service" >/dev/null +sudo -n -l /usr/bin/systemctl stop "$service" >/dev/null + +release=$(mktemp -d "$root/releases/$sha.XXXXXXXX") +switch_dir= +finished=false + +switch_to() { + ln -s -- "$1" "$switch_dir/current" && + mv -Tf -- "$switch_dir/current" "$current" +} + +cleanup() { + local status=$? + trap - EXIT + trap '' INT TERM + set +e + if ! $finished && [[ $(readlink "$current") == "$release" ]]; then + echo "Deployment failed. Restoring the previous service state." >&2 + if [[ -n $previous ]]; then + rm -f -- "$switch_dir/current" + if ! switch_to "$previous" || ! sudo -n /usr/bin/systemctl restart "$service" || + ! wait_for_service; then + echo "Rollback failed. Check journalctl -u $service." >&2 + status=1 + fi + elif ! sudo -n /usr/bin/systemctl stop "$service" || ! rm -f -- "$current"; then + echo "Could not clean up the first deployment. Check $service and $current." >&2 + status=1 + fi + fi + if ! $finished && [[ $(readlink "$current") != "$release" ]]; then + rm -rf -- "$release" + fi + if [[ -n $switch_dir ]]; then + rm -rf -- "$switch_dir" + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +switch_dir=$(mktemp -d "$root/.switch.XXXXXXXX") +install -m 0755 .deploy/webserver "$release/webserver" +install -d -m 0755 "$release/frontend" "$release/backend" +cp -r frontend/dist "$release/frontend/dist" +cp -r backend/templates "$release/backend/templates" +test -s "$release/frontend/dist/index.html" +test -s "$release/backend/templates/404.html" +ln -s "$root/shared/logs" "$release/logs" +chmod -R a+rX "$release" + +switch_to "$release" +sudo -n /usr/bin/systemctl restart "$service" +wait_for_service +finished=true +echo "Deployed $sha to $release" diff --git a/docs/design-notes.md b/docs/design-notes.md new file mode 100644 index 0000000..1c4bd4c --- /dev/null +++ b/docs/design-notes.md @@ -0,0 +1,16 @@ +# 页面设计与交互约定 + +保留 AutoLibrary 的书本标识、绿色强调色、图书馆文案与真实软件截图。首页用紧凑功能带、横向标签与通栏截图、编号资源清单形成不同的信息层次,避免每节重复大标题与卡片网格。 + +参考 [Paco Coursey](https://paco.me/) 的克制排版与目录组织,以及 [Zed](https://zed.dev/) 对真实产品界面的呈现;不引入其品牌元素或外部素材。 + +- `ThemeToggle.vue` 以太阳/月亮图标切换当前主题,状态由 `stores/theme.ts` 统一维护并持久保存。首次访问仍跟随系统;图标旋转、淡入和缩放遵守减少动态效果偏好。 +- 顶部主题与登录按钮统一为 32px 高;键盘焦点保留清晰轮廓。 +- 功能与画廊板块标题共用 `home-section-title`:桌面端 22px、手机端 20px,字重、行高和标题下方间距一致。标题旁不添加辅助说明或装饰圆点。 +- `ProductGallery.vue` 管理截图与目录内容;`useGallery.ts` 管理自动播放。画廊至少 20% 可见且页面处于前台时,每 6 秒切换;鼠标悬停和控件获得焦点不暂停,手动选择后重新计时。提供播放/暂停、上一张/下一张和左右方向键操作;用户主动暂停后,手动选择不会重新开启播放。系统要求减少动态效果时默认停止自动播放。 +- 画廊样式放在 `styles/home.css`,桌面端标签位于通栏截图上方,手机端改为两列。保留当前标签底部的播放进度条,界面不展示切换间隔文案。第一张截图跟随网站深浅主题,其余保留原始实机截图。 +- 验证码始终使用明亮背景、接近原生的 150×50 比例,不反色或滤镜处理;输入框有明确标签,点击图片可刷新。 +- 首页与下载页的平台标识共用 `PlatformLogo.vue`。`DownloadSource.vue` 使用原生 `details/summary` 展开 Gitea、云盘资源;桌面悬停展开,也支持点击、键盘和 Escape,手机端点击后在页面内展开。同一时刻只展开一个入口,云盘提取码位于资源链接上方。 +- 主按钮悬停与键盘焦点使用独立的背景和文字颜色变量,不整体叠加亮度滤镜,以维持深浅主题下的清晰对比。 + +维护时运行 `npm --prefix frontend run build` 完成类型、代码风格与生产构建检查。另在浏览器检查双主题、手机宽度、自动轮播与验证码显示,无需新增测试用例。 diff --git a/docs/gitea-deployment.md b/docs/gitea-deployment.md new file mode 100644 index 0000000..2a9ac51 --- /dev/null +++ b/docs/gitea-deployment.md @@ -0,0 +1,91 @@ +# 通过 Gitea Actions 部署官网 + +推送到网站仓库的 `main` 后,Gitea 把任务交给 Ubuntu 部署机上的专用 Runner。Runner 检出该次提交,使用锁文件安装前端依赖,执行前端构建、`go test ./...` 和启用 CGO 的 Go 编译。每次发布生成独立目录,原子切换 `current`,重启 systemd 服务并检查首页和 `/api/health`。检查失败时自动切回上一发布,并将本次任务标记为失败。 + +## 1. 准备 Ubuntu 部署机 + +安装 Bash、Git、curl、sudo、`build-essential`、`util-linux`、Go 1.25.6 或更新版本、Node.js 22.13 或更新版本。确认 Runner 的服务环境中能找到 `git`、`node`、`npm`、`go` 和 `gcc`,并能访问 Gitea、GitHub 上固定版本的 checkout Action、npm registry 和 Go 模块代理。 + +部署使用两个专用账号。首次创建账号及目录时运行以下命令。已有同名账号时复用,并确认组和权限与示例相符。 + +```sh +sudo useradd --system --user-group --create-home --home-dir /var/lib/autolibrary-runner autolibrary-deploy +sudo useradd --system --user-group --no-create-home autolibrary +sudo chmod 0700 /var/lib/autolibrary-runner +sudo install -d -o root -g autolibrary-deploy -m 1775 /opt/autolibrary +sudo install -d -o autolibrary-deploy -g autolibrary-deploy -m 0755 /opt/autolibrary/releases +sudo install -d -o root -g autolibrary -m 0771 /opt/autolibrary/shared +sudo install -d -o autolibrary -g autolibrary -m 0750 /opt/autolibrary/shared/logs +``` + +从现有站点迁移时,按 [Ubuntu 部署说明](migration-to-ubuntu.md) 停服备份,再迁入现有配置和数据。新站点可以从 `config.example.json` 起步并填入实际参数。生产配置路径为 `/opt/autolibrary/shared/config.json`,文件权限设为 `0640`,所有者设为 `root:autolibrary`。配置应包括: + +- SQLite 数据库和主题文件使用发布目录之外的绝对路径,例如 `/opt/autolibrary/shared/themes.db` 与 `/opt/autolibrary/shared/themes_files`。运行用户 `autolibrary` 需要对数据库所在目录和主题文件目录有写权限。 +- `frontend.host` 监听 `127.0.0.1`,`frontend.static.path` 使用 `./frontend/dist`。生产端口与现有 Nginx upstream 对齐。 +- 保留生产 API Key、Casdoor 配置及独立运行的 Casdoor 服务。启用 API Key 校验时填写实际的 Key。 + +已有 `current` 时,它应为指向上一版的符号链接。核实上一版可用新 unit 和共享配置启动,并把该链接本身交给部署账号(`sudo chown -h autolibrary-deploy:autolibrary-deploy /opt/autolibrary/current`),以便部署替换和回滚。若原站使用其他 unit,在第一次自动发布前停止并禁用该旧 unit,避免争用端口。 + +## 2. 安装网站服务和 Runner + +从网站源码目录安装 unit。它在首次发布时启动。 + +```sh +sudo install -m 0644 deploy/autolibrary-webserver.service /etc/systemd/system/autolibrary-webserver.service +sudo systemctl daemon-reload +sudo systemctl enable autolibrary-webserver.service +sudo visudo -f /etc/sudoers.d/autolibrary-deploy +``` + +加入以下精确授权,使 Runner 可以操作网站 unit。 + +```sudoers +autolibrary-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart autolibrary-webserver.service, /usr/bin/systemctl stop autolibrary-webserver.service +``` + +在 Gitea 创建一个空的网站源码仓库,启用仓库的 Actions 功能并保护 `main`。使用该网站仓库的 Runner 注册令牌,在部署机安装 [Gitea Runner](https://docs.gitea.com/runner/),然后以部署账号交互注册: + +```sh +sudo -u autolibrary-deploy -H sh -c 'cd "$HOME" && gitea-runner register' +``` + +输入实例地址和仓库级令牌,标签只填写 `autolibrary-production:host`。以同一账号和工作目录运行 `gitea-runner daemon`。可用下面的 systemd unit 持久运行,将 `ExecStart` 与 `PATH` 调整到实际安装位置。 + +```ini +[Unit] +Description=AutoLibrary Gitea Runner +Wants=network-online.target +After=network-online.target + +[Service] +User=autolibrary-deploy +WorkingDirectory=/var/lib/autolibrary-runner +Environment=PATH=/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/local/bin/gitea-runner daemon +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +例如保存为 `/etc/systemd/system/autolibrary-runner.service` 后,执行 `sudo systemctl daemon-reload` 和 `sudo systemctl enable --now autolibrary-runner.service`。旧版 Runner 的二进制名可能为 `act_runner`,按实际名称替换。确认仓库设置页中 Runner 在线,并且标签与工作流的 `runs-on` 一致。宿主机任务使用部署账号的权限直接运行,只将这个 Runner 分配给受信任的网站仓库。 + +## 3. 首次推送 + +在 Gitea 仓库的 Actions Variables 中将 `AUTOLIBRARY_PORT` 设为生产 `frontend.port`;省略时为 `3003`,若沿用通用示例配置则设置为 `3000`。 + +在本地网站源码目录绑定新建仓库的克隆地址并推送: + +```sh +git remote add origin +git push -u origin main +``` + +在 Gitea 的 Actions 页确认部署任务完成。之后日常发布只需推送 `main`。发布脚本在切换前读取远端 `main`,已过时的任务会跳过部署。生产密钥、数据库、上传目录和 Casdoor 实例数据由部署机保存。 + +## 排查和回滚 + +- 任务排队时,检查仓库 Actions 设置、Runner 在线状态和标签。 +- 重启或健康检查失败时,检查 Actions 日志和 `sudo journalctl -u autolibrary-webserver.service -n 100`。检查端口、共享配置、数据路径和权限。 +- 发生业务回归时,用 `git revert` 后推送 `main` 重新构建上一份可用代码。紧急切回已有目录时,先确保 Runner 没有在部署,再按 [Ubuntu 部署说明](migration-to-ubuntu.md#回滚) 切换 `current`。清理历史发布目录前确认其未被 `current` 使用,并至少保留一个可用的上一版。 +- 自动回滚切换的是程序和静态文件。涉及数据库结构变化时,上线前备份共享数据,并确认旧版本兼容变更后的结构。 diff --git a/docs/migration-to-ubuntu.md b/docs/migration-to-ubuntu.md new file mode 100644 index 0000000..549da8d --- /dev/null +++ b/docs/migration-to-ubuntu.md @@ -0,0 +1,122 @@ +# AutoLibrary Ubuntu 部署与迁移 + +日常发布可交给 [Gitea 自动部署](gitea-deployment.md)。本页用于首次迁移、数据备份与人工恢复。 + +## 部署组成 + +- Go 服务器:`webserver frontend`、`webserver backend`,或 `webserver start` 同时运行。 +- 前端构建:Vue 应用的 `frontend/dist`,部署后无需 Node 服务。 +- 运行数据:现有的三个 SQLite 数据库、主题文件目录、Casdoor 数据与配置。 +- 入口:Nginx 继续反向代理 Go 前端;Go 将 `/api/*` 去掉 `/api` 前缀后代理到后端。 + +保留当前实际端口和域名。仓库内 Nginx 示例使用前端 3003、后端 8080,通用配置示例使用前端 3000。以部署机 `config.json` 为准。 + +## 1. 准备新版本 + +开发或构建机器需要 Go 1.25.6+ 和 Node.js 22.13+。推荐使用 Node 当前 LTS。 + +```sh +npm --prefix frontend ci +npm --prefix frontend run build +go test ./... +go build -o webserver . +``` + +SQLite 驱动使用 CGO;建议在 Ubuntu 上安装 `build-essential` 并在目标平台构建 Go 可执行文件,不要把 Windows 可执行文件直接部署到 Linux。 + +为每次发布准备单独目录,例如 `/opt/autolibrary/releases/20261016`。目录应包含: + +```text +webserver +frontend/dist/ +backend/templates/ +config.json # 部署机自己的配置,不用示例覆盖 +``` + +保留 `frontend/dist` 中的完整资源目录,包括字体、截图与品牌图标。 + +## 2. 备份运行数据 + +停止旧 AutoLibrary 服务,保证 SQLite 备份与主题文件一致,再备份: + +- 原部署程序和前端资源。 +- `config.json`。 +- `versions.db`、`bulletins.db`、`themes.db`,以及存在的 `-wal`、`-shm` 文件。 +- 整个 `themes_files` 目录。 +- Casdoor 数据库、证书和配置。 + +本次不重置数据库或 Casdoor。使用部署机既有账户;不要重新导入初始化数据覆盖账户。 + +## 3. 配置路径 + +推荐将持久数据放在 `/opt/autolibrary/shared/`,在 `config.json` 中使用绝对路径。例如: + +```json +{ + "backend": { + "host": "127.0.0.1", + "port": "8080", + "database": { + "versions": { "path": "/opt/autolibrary/shared/versions.db" }, + "bulletins": { "path": "/opt/autolibrary/shared/bulletins.db" }, + "themes": { "path": "/opt/autolibrary/shared/themes.db" }, + "themes_files": { "path": "/opt/autolibrary/shared/themes_files" } + } + }, + "frontend": { + "host": "127.0.0.1", + "port": "3003", + "static": { "path": "./frontend/dist" } + } +} +``` + +以上仅展示需调整的字段;合并至实际完整配置,保留原 API Key、Casdoor client ID、secret 和证书。完整无密钥模板见根目录 `config.example.json`。 + +旧配置若仍指向 `frontend/public`,服务器在其同级找到 `dist/index.html` 时会使用新构建;仍建议显式改为 `frontend/dist`。 + +启动会为版本表添加 `gitea_*_url` 和 `release_notes_url` 列,不删除旧字段。历史 GitHub 下载地址不会被盲目改写。通过原版本管理 API 填入真实 Gitea 下载地址;未填写时,新官网引导访问 Gitea 发行页,云盘仍按原数据提供。 + +## 4. systemd + +将 `/opt/autolibrary/current` 指向当前发布目录,并配置: + +```ini +[Unit] +Description=AutoLibrary Website +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/autolibrary/current +ExecStart=/opt/autolibrary/current/webserver start --config /opt/autolibrary/shared/config.json +Restart=on-failure +RestartSec=5 +TimeoutStopSec=20 + +[Install] +WantedBy=multi-user.target +``` + +Go 服务收到 SIGTERM 后先等待请求结束,再关闭数据库。Casdoor 继续使用原独立服务运行;若配置了服务依赖,沿用现有的 Casdoor unit 名称。 + +Nginx 通常无需更改。检查 `proxy_pass` 指向的端口与当前配置一致。`/changelog` 的永久跳转和页面路由由 Go 处理;不要增加将所有未知资源重写到首页的规则。 + +## 5. 上线验证 + +```sh +curl -I http://127.0.0.1:3003/ +curl -I http://127.0.0.1:3003/marketplace/profile +curl -I http://127.0.0.1:3003/changelog +curl -i http://127.0.0.1:3003/not-a-real-page +curl http://127.0.0.1:3003/api/download/latest +curl http://127.0.0.1:8080/health +``` + +应分别得到页面成功、深层页面成功、301 跳转 `/downloads`、404、真实稳定版或空数据、健康状态。检查浏览器中的深浅主题、下载入口、市场筛选、登录、主题上传与管理员审核。 + +网站不再实时请求 GitHub 或 Gitea API 获取版本。发行说明与源码入口统一使用 Gitea;公告服务仍保留。 + +## 回滚 + +如需回滚,停止新服务,将 `current` 指回旧发布目录并恢复原配置,再启动旧程序。新增版本列属于增量改动,旧程序可忽略;若要恢复上线前的数据状态,应在停服后恢复同一批备份的数据库和主题文件。不要只回滚其中一部分。 diff --git a/docs/nginx-autolibrary.conf b/docs/nginx-autolibrary.conf new file mode 100644 index 0000000..914db1f --- /dev/null +++ b/docs/nginx-autolibrary.conf @@ -0,0 +1,120 @@ +# ============================================================ +# AutoLibrary Nginx Config — autolibrary.kenanzhu.com +# ============================================================ + +# --- Root domain redirect to www --- +server { + listen 80; + server_name autolibrary.kenanzhu.com; + + if ($host = autolibrary.kenanzhu.com) { + return 301 https://$host$request_uri; + } # managed by Certbot + + return 404; # managed by Certbot +} + +server { + listen 443 ssl; # managed by Certbot + server_name autolibrary.kenanzhu.com; + + ssl_certificate /etc/letsencrypt/live/autolibrary.kenanzhu.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/autolibrary.kenanzhu.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + + return 301 https://www.autolibrary.kenanzhu.com$request_uri; +} + +# --- www Frontend --- +server { + listen 80; + server_name www.autolibrary.kenanzhu.com; + + if ($host = www.autolibrary.kenanzhu.com) { + return 301 https://$host$request_uri; + } # managed by Certbot + + return 404; # managed by Certbot +} + +server { + listen 443 ssl; # managed by Certbot + server_name www.autolibrary.kenanzhu.com; + + ssl_certificate /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + + location / { + proxy_pass http://127.0.0.1:3003; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + error_page 404 /errors/404; + error_page 500 502 503 504 /errors/500; +} + +# --- api Backend --- +server { + listen 80; + server_name api.autolibrary.kenanzhu.com; + + if ($host = api.autolibrary.kenanzhu.com) { + return 301 https://$host$request_uri; + } # managed by Certbot + + return 404; # managed by Certbot +} + +server { + listen 443 ssl; # managed by Certbot + server_name api.autolibrary.kenanzhu.com; + + ssl_certificate /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + + location / { + proxy_pass http://127.0.0.1:8080/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://127.0.0.1:8080/health; + access_log off; + } +} + +# --- auth Casdoor --- +server { + listen 80; + server_name auth.autolibrary.kenanzhu.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name auth.autolibrary.kenanzhu.com; + + ssl_certificate /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://127.0.0.1:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..f8bf35d --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +public/ +package-lock.json diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..2b41151 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": false, + "printWidth": 100, + "trailingComma": "all", + "htmlWhitespaceSensitivity": "ignore" +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..73db67b --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,17 @@ +import js from '@eslint/js' +import ts from 'typescript-eslint' +import vue from 'eslint-plugin-vue' +import globals from 'globals' + +export default ts.config( + { ignores: ['dist/**', 'node_modules/**', 'obfuscate/**', 'public/**'] }, + js.configs.recommended, + ...ts.configs.recommended, + ...vue.configs['flat/essential'], + { + files: ['**/*.vue'], + languageOptions: { parserOptions: { parser: ts.parser } }, + rules: { 'vue/multi-word-component-names': 'off' }, + }, + { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, +) diff --git a/frontend/frontend.go b/frontend/frontend.go new file mode 100644 index 0000000..b5772f6 --- /dev/null +++ b/frontend/frontend.go @@ -0,0 +1,117 @@ +package frontend + +import ( + "autolibrary.top/webserver/config" + "autolibrary.top/webserver/frontend/handlers/errors_handler" + "autolibrary.top/webserver/utils" + "context" + "fmt" + "github.com/gin-gonic/gin" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" +) + +var hashedAsset = regexp.MustCompile(`-[a-zA-Z0-9_-]{8,}\.[a-z0-9]+$`) + +func createProxy(targetURL string) gin.HandlerFunc { + target, _ := url.Parse(targetURL) + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + log.Printf("[W] backend proxy: %v", err) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"code":502,"msg":"Backend service is unavailable","data":null}`)) + } + return func(c *gin.Context) { c.Request.URL.Path = c.Param("path"); proxy.ServeHTTP(c.Writer, c.Request) } +} + +// ResolveStaticPath also accepts the legacy public configuration if a sibling +// production build exists. The configured path can be absolute or relative. +func ResolveStaticPath(configured string) (string, error) { + path := filepath.Clean(configured) + if filepath.Base(path) == "public" { + sibling := filepath.Join(filepath.Dir(path), "dist") + if _, err := os.Stat(filepath.Join(sibling, "index.html")); err == nil { + path = sibling + } + } + if _, err := os.Stat(filepath.Join(path, "index.html")); err != nil { + return "", fmt.Errorf("frontend build missing at %s: run npm --prefix frontend run build: %w", path, err) + } + return path, nil +} + +func NewRouter(cfg *config.Config) (*gin.Engine, error) { + staticPath, err := ResolveStaticPath(cfg.Frontend.Static.Path) + if err != nil { + return nil, err + } + entry, err := os.ReadFile(filepath.Join(staticPath, "index.html")) + if err != nil { + return nil, err + } + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + pages := errors_handler.New(entry) + r.Use(pages.APIAccessMiddleware()) + r.Use(func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + if strings.HasPrefix(c.Request.URL.Path, "/assets/") { + if hashedAsset.MatchString(c.Request.URL.Path) { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + } else { + c.Header("Cache-Control", "public, max-age=3600") + } + } else { + c.Header("Cache-Control", "no-cache") + } + c.Next() + }) + r.Static("/assets", filepath.Join(staticPath, "assets")) + backendHost := cfg.Backend.Host + if backendHost == "0.0.0.0" || backendHost == "::" || backendHost == "" { + backendHost = "127.0.0.1" + } + r.Any("/api/*path", createProxy("http://"+net.JoinHostPort(backendHost, cfg.Backend.Port))) + serve := func(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", entry) } + for _, path := range []string{"/", "/downloads", "/marketplace", "/marketplace/upload", "/marketplace/profile", "/marketplace/admin"} { + r.GET(path, serve) + r.HEAD(path, serve) + } + for _, method := range []string{http.MethodGet, http.MethodHead} { + r.Handle(method, "/changelog", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/downloads") }) + } + pages.RegisterGetErrorRoutes(r, []int{400, 401, 403, 404, 500, 502, 503}) + r.NoRoute(func(c *gin.Context) { + if c.Request.URL.Path == "/manuals" || strings.HasPrefix(c.Request.URL.Path, "/manuals/") { + c.Redirect(http.StatusMovedPermanently, "https://manuals.autolibrary.kenanzhu.com") + return + } + if strings.HasPrefix(c.Request.URL.Path, "/api/") { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "API route not found", "data": nil}) + return + } + pages.GetErrorsPage(c, http.StatusNotFound) + }) + return r, nil +} + +func StartFrontend() error { return StartFrontendContext(context.Background()) } +func StartFrontendContext(ctx context.Context) error { + cfg := config.GetConfig() + router, err := NewRouter(cfg) + if err != nil { + return err + } + address := net.JoinHostPort(cfg.Frontend.Host, cfg.Frontend.Port) + log.Printf("[I] frontend server is running on: http://%s", address) + return utils.ServeHTTP(ctx, address, router) +} diff --git a/frontend/handlers/all.go b/frontend/handlers/all.go new file mode 100644 index 0000000..510cdf0 --- /dev/null +++ b/frontend/handlers/all.go @@ -0,0 +1,5 @@ +package handlers + +import ( + _ "autolibrary.top/webserver/frontend/handlers/errors_handler" +) diff --git a/frontend/handlers/errors_handler/handler.go b/frontend/handlers/errors_handler/handler.go new file mode 100644 index 0000000..e099767 --- /dev/null +++ b/frontend/handlers/errors_handler/handler.go @@ -0,0 +1,45 @@ +package errors_handler + +import ( + "fmt" + "github.com/gin-gonic/gin" + "net/http" + "net/url" + "strings" +) + +// Handler renders the same application shell with the actual HTTP error code. +// It owns its entry data rather than using process-global mutable template state. +type Handler struct{ entry []byte } + +func New(entry []byte) *Handler { return &Handler{entry: entry} } + +func (h *Handler) GetErrorsPage(c *gin.Context, status int) { + c.Header("Cache-Control", "no-store") + c.Data(status, "text/html; charset=utf-8", h.entry) +} + +func (h *Handler) RegisterGetErrorRoutes(r *gin.Engine, statuses []int) { + for _, code := range statuses { + serve := func(c *gin.Context) { h.GetErrorsPage(c, code) } + path := fmt.Sprintf("/errors/%d", code) + r.GET(path, serve) + r.HEAD(path, serve) + } +} + +func (h *Handler) APIAccessMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/api/") { + referer := c.GetHeader("Referer") + if referer != "" { + parsed, err := url.Parse(referer) + if err != nil || parsed.Host != c.Request.Host { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 403, "msg": "Cross-origin API request denied", "data": nil}) + return + } + } + } + c.Next() + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..39489c7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + AutoLibrary · 让专注,从容开始 + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9b3921c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3043 @@ +{ + "name": "autolibrary-web", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "autolibrary-web", + "version": "2.0.0", + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@lucide/vue": "^1.57.0", + "pinia": "^3.0.3", + "vue": "^3.5.22", + "vue-router": "^4.6.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.18.0", + "@vitejs/plugin-vue": "^6.0.1", + "eslint": "^10.12.0", + "eslint-plugin-vue": "^10.5.1", + "globals": "^16.5.0", + "prettier": "^3.6.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.0", + "vite": "^7.1.9", + "vue-tsc": "^3.1.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lucide/vue": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.57.0.tgz", + "integrity": "sha512-l/D9mr+8Wb8uHl2vzHfcGVRkunJnq0NDmv8HFwXAL3UiYYxuNpDSd1uaCVqeR8+I1XY70dWA+R5W/pSpXWilMw==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.13.tgz", + "integrity": "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/copy-anything": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.5.tgz", + "integrity": "sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.11.1.tgz", + "integrity": "sha512-pasVXhl7wUmIvhujoqs/m0mJ9r59QW1IR9XXYKGR0FETzSM+Hhlf4qKIYgWiDDP+9YKv4YfbwskD7LqIdVcgQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.4", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.13.tgz", + "integrity": "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.13" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8852258 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "autolibrary-web", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "typecheck": "vue-tsc --noEmit", + "check": "npm run typecheck && eslint . && prettier --check .", + "format": "prettier --write .", + "build": "npm run check && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "pinia": "^3.0.3", + "vue": "^3.5.22", + "vue-router": "^4.6.3", + "@lucide/vue": "^1.57.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.18.0", + "@vitejs/plugin-vue": "^6.0.1", + "eslint": "^10.12.0", + "eslint-plugin-vue": "^10.5.1", + "globals": "^16.5.0", + "prettier": "^3.6.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.0", + "vite": "^7.1.9", + "vue-tsc": "^3.1.1" + } +} diff --git a/frontend/public/assets/favicon.svg b/frontend/public/assets/favicon.svg new file mode 100644 index 0000000..417c038 --- /dev/null +++ b/frontend/public/assets/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/images/index/AutoLibrary-Dark.png b/frontend/public/assets/images/index/AutoLibrary-Dark.png new file mode 100644 index 0000000..fffd157 Binary files /dev/null and b/frontend/public/assets/images/index/AutoLibrary-Dark.png differ diff --git a/frontend/public/assets/images/index/AutoLibrary-Light.png b/frontend/public/assets/images/index/AutoLibrary-Light.png new file mode 100644 index 0000000..f4c4757 Binary files /dev/null and b/frontend/public/assets/images/index/AutoLibrary-Light.png differ diff --git a/frontend/public/assets/images/index/AutoLibrary多用户管理.png b/frontend/public/assets/images/index/AutoLibrary多用户管理.png new file mode 100644 index 0000000..a71fba0 Binary files /dev/null and b/frontend/public/assets/images/index/AutoLibrary多用户管理.png differ diff --git a/frontend/public/assets/images/index/AutoLibrary定时任务.png b/frontend/public/assets/images/index/AutoLibrary定时任务.png new file mode 100644 index 0000000..9b78a1d Binary files /dev/null and b/frontend/public/assets/images/index/AutoLibrary定时任务.png differ diff --git a/frontend/public/assets/images/index/AutoLibrary座位可视布局.png b/frontend/public/assets/images/index/AutoLibrary座位可视布局.png new file mode 100644 index 0000000..7d77a3f Binary files /dev/null and b/frontend/public/assets/images/index/AutoLibrary座位可视布局.png differ diff --git a/frontend/public/assets/licenses/inter-OFL.txt b/frontend/public/assets/licenses/inter-OFL.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/frontend/public/assets/licenses/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/public/assets/licenses/lucide-ISC.txt b/frontend/public/assets/licenses/lucide-ISC.txt new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/frontend/public/assets/licenses/lucide-ISC.txt @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +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. diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..b2d89a5 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,127 @@ + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..434c0a0 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,70 @@ +export class ApiError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message) + } +} +const messages: Record = { + 401: '登录已过期,请重新登录', + 403: '你没有执行此操作的权限', + 409: '你已经上传过同名主题', + 502: '认证服务暂时不可用', + 503: '服务暂时不可用', +} +function headersFor(options: RequestInit, authenticated: boolean): Headers { + const headers = new Headers(options.headers) + headers.set('Accept', 'application/json') + if (authenticated) { + const token = localStorage.getItem('casdoor_token') + if (token) headers.set('Authorization', `Bearer ${token}`) + } + return headers +} +export async function request( + path: string, + options: RequestInit = {}, + authenticated = false, +): Promise { + const timeout = AbortSignal.timeout(30_000) + const response = await fetch(`/api${path}`, { + ...options, + headers: headersFor(options, authenticated), + signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout, + }) + const body = await response.json().catch(() => null) + if (response.status === 401 && authenticated) { + localStorage.removeItem('casdoor_token') + localStorage.removeItem('casdoor_user') + window.dispatchEvent(new Event('session-expired')) + } + if ( + !response.ok || + !body || + (typeof body.code === 'number' && body.code !== 1 && (body.code < 200 || body.code >= 300)) + ) { + throw new ApiError( + response.status === 401 && path === '/marketplace/auth/login' + ? '账户或密码错误' + : messages[response.status] || body?.msg || '请求失败,请稍后重试', + response.status, + ) + } + return (typeof body.code === 'number' && 'data' in body ? body.data : body) as T +} +export function json(method: string, data: unknown): RequestInit { + return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) } +} +export async function downloadFile(path: string, filename: string): Promise { + const response = await fetch(`/api${path}`, { + headers: headersFor({}, true), + signal: AbortSignal.timeout(60_000), + }) + if (!response.ok) + throw new ApiError(messages[response.status] || '下载失败,请稍后重试', response.status) + const url = URL.createObjectURL(await response.blob()) + const link = Object.assign(document.createElement('a'), { href: url, download: filename }) + link.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} diff --git a/frontend/src/components/AppDialog.vue b/frontend/src/components/AppDialog.vue new file mode 100644 index 0000000..74bd34f --- /dev/null +++ b/frontend/src/components/AppDialog.vue @@ -0,0 +1,56 @@ + + diff --git a/frontend/src/components/AsyncState.vue b/frontend/src/components/AsyncState.vue new file mode 100644 index 0000000..65638e0 --- /dev/null +++ b/frontend/src/components/AsyncState.vue @@ -0,0 +1,14 @@ + + diff --git a/frontend/src/components/AuthDialog.vue b/frontend/src/components/AuthDialog.vue new file mode 100644 index 0000000..9ffd652 --- /dev/null +++ b/frontend/src/components/AuthDialog.vue @@ -0,0 +1,165 @@ + + diff --git a/frontend/src/components/DownloadSource.vue b/frontend/src/components/DownloadSource.vue new file mode 100644 index 0000000..3d9b26d --- /dev/null +++ b/frontend/src/components/DownloadSource.vue @@ -0,0 +1,106 @@ + + + diff --git a/frontend/src/components/Pagination.vue b/frontend/src/components/Pagination.vue new file mode 100644 index 0000000..dcb22f8 --- /dev/null +++ b/frontend/src/components/Pagination.vue @@ -0,0 +1,29 @@ + + diff --git a/frontend/src/components/PlatformLogo.vue b/frontend/src/components/PlatformLogo.vue new file mode 100644 index 0000000..9c27867 --- /dev/null +++ b/frontend/src/components/PlatformLogo.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/components/ProductGallery.vue b/frontend/src/components/ProductGallery.vue new file mode 100644 index 0000000..3bfd57b --- /dev/null +++ b/frontend/src/components/ProductGallery.vue @@ -0,0 +1,136 @@ + + + diff --git a/frontend/src/components/ReviewQueue.vue b/frontend/src/components/ReviewQueue.vue new file mode 100644 index 0000000..7fa362d --- /dev/null +++ b/frontend/src/components/ReviewQueue.vue @@ -0,0 +1,130 @@ + +