commit a49a11774a817a1cc487a0f3585238fac95c228f Author: Rostislav Dugin Date: Thu Jun 5 16:11:50 2025 +0300 FEATURE (init): Make internal project public diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f829030 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# docker-compose.yml +DB_NAME=postgresus +DB_USERNAME=postgres +DB_PASSWORD=Q1234567 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..657712a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +postgresus_data/ +.env +pgdata/ \ No newline at end of file diff --git a/backend/.cursor/rules/codestyle-rule.mdc b/backend/.cursor/rules/codestyle-rule.mdc new file mode 100644 index 0000000..68546d8 --- /dev/null +++ b/backend/.cursor/rules/codestyle-rule.mdc @@ -0,0 +1,14 @@ +--- +description: +globs: +alwaysApply: true +--- +Always place private methods to the bottom of file + +Code should look like: + +type SomeService struct { + func PublicMethod(...) ... + + func privateMethod(...) ... +} \ No newline at end of file diff --git a/backend/.cursor/rules/comments.mdc b/backend/.cursor/rules/comments.mdc new file mode 100644 index 0000000..32eb9be --- /dev/null +++ b/backend/.cursor/rules/comments.mdc @@ -0,0 +1,7 @@ +--- +description: +globs: +alwaysApply: true +--- +Do not write obsious comments. +Write meaningful code, give meaningful names diff --git a/backend/.cursor/rules/controllers-rule.mdc b/backend/.cursor/rules/controllers-rule.mdc new file mode 100644 index 0000000..8aac5c8 --- /dev/null +++ b/backend/.cursor/rules/controllers-rule.mdc @@ -0,0 +1,55 @@ +--- +description: +globs: +alwaysApply: true +--- +1. When we write controller: +- we combine all routes to single controller +- names them as .WhatWeDo (not "handlers") concept + +2. We use gin and *gin.Context for all routes. +Example: + +func (c *TasksController) GetAvailableTasks(ctx *gin.Context) ... + +3. We document all routes with Swagger in the following format: + +// SignIn +// @Summary Authenticate a user +// @Description Authenticate a user with email and password +// @Tags users +// @Accept json +// @Produce json +// @Param request body SignInRequest true "User signin data" +// @Success 200 {object} SignInResponse +// @Failure 400 +// @Router /users/signin [post] + +Do not forget to write docs. +You can avoid description if it is useless. +Specify particular acceping \ producing models + +4. All controllers should have RegisterRoutes method which receives +RouterGroup (always put this routes on the top of file under controller definition) + +Example: + +func (c *OrderController) RegisterRoutes(router *gin.RouterGroup) { + router.POST("/bots/users/orders/generate", c.GenerateOrder) + router.POST("/bots/users/orders/generate-by-admin", c.GenerateOrderByAdmin) + router.GET("/bots/users/orders/mark-as-paid-by-admin", c.MarkOrderAsPaidByAdmin) + router.GET("/bots/users/orders/payments-by-bot", c.GetOrderPaymentsByBot) + router.GET("/bots/users/orders/payments-by-user", c.GetOrderPaymentsByUser) + router.GET("/bots/users/orders/orders-by-user-for-admin", c.GetOrdersByUserForAdmin) + router.POST("/bots/users/orders/orders-by-user-for-user", c.GetOrdersByUserForUser) + router.POST("/bots/users/orders/order", c.GetOrder) + router.POST("/bots/users/orders/cancel-subscription-by-user", c.CancelSubscriptionByUser) + router.GET("/bots/users/orders/cancel-subscription-by-admin", c.CancelSubscriptionByAdmin) + router.GET( + "/bots/users/orders/cancel-subscriptions-by-payment-option", + c.CancelSubscriptionsByPaymentOption, + ) +} + +5. Check that use use valid .Query("param") and .Param("param") methods. +If route does not have param - use .Query("query") \ No newline at end of file diff --git a/backend/.cursor/rules/di-rule.mdc b/backend/.cursor/rules/di-rule.mdc new file mode 100644 index 0000000..63124fd --- /dev/null +++ b/backend/.cursor/rules/di-rule.mdc @@ -0,0 +1,74 @@ +--- +description: +globs: +alwaysApply: true +--- +For DI files use implicit fields declaration styles (espesially +for controllers, services, repositories, use cases, etc., not simple +data structures). + +So, instead of: + +var orderController = &OrderController{ + orderService: orderService, + botUserService: bot_users.GetBotUserService(), + botService: bots.GetBotService(), + userService: users.GetUserService(), +} + +Use: + +var orderController = &OrderController{ + orderService, + bot_users.GetBotUserService(), + bots.GetBotService(), + users.GetUserService(), +} + +This is needed to avoid forgetting to update DI style +when we add new dependency. + +--- + +Please force such usage if file look like this (see some +services\controllers\repos definitions and getters): + +var orderBackgroundService = &OrderBackgroundService{ + orderService: orderService, + orderPaymentRepository: orderPaymentRepository, + botService: bots.GetBotService(), + paymentSettingsService: payment_settings.GetPaymentSettingsService(), + + orderSubscriptionListeners: []OrderSubscriptionListener{}, +} + +var orderController = &OrderController{ + orderService: orderService, + botUserService: bot_users.GetBotUserService(), + botService: bots.GetBotService(), + userService: users.GetUserService(), +} + +func GetUniquePaymentRepository() *repositories.UniquePaymentRepository { + return uniquePaymentRepository +} + +func GetOrderPaymentRepository() *repositories.OrderPaymentRepository { + return orderPaymentRepository +} + +func GetOrderService() *OrderService { + return orderService +} + +func GetOrderController() *OrderController { + return orderController +} + +func GetOrderBackgroundService() *OrderBackgroundService { + return orderBackgroundService +} + +func GetOrderRepository() *repositories.OrderRepository { + return orderRepository +} diff --git a/backend/.cursor/rules/migrations-rule.mdc b/backend/.cursor/rules/migrations-rule.mdc new file mode 100644 index 0000000..3c0fed8 --- /dev/null +++ b/backend/.cursor/rules/migrations-rule.mdc @@ -0,0 +1,27 @@ +--- +description: +globs: +alwaysApply: true +--- +When writting migrations: + +- write them for PostgreSQL +- for PRIMARY UUID keys use gen_random_uuid() +- for time use TIMESTAMPTZ (timestamp with zone) +- split table, constraint and indexes declaration (table first, them other one by one) +- format SQL in pretty way (add spaces, align columns types), constraints split by lines. The example: + +CREATE TABLE marketplace_info ( + bot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT NOT NULL, + short_description TEXT NOT NULL, + tutorial_url TEXT, + info_order BIGINT NOT NULL DEFAULT 0, + is_published BOOLEAN NOT NULL DEFAULT FALSE +); + +ALTER TABLE marketplace_info_images + ADD CONSTRAINT fk_marketplace_info_images_bot_id + FOREIGN KEY (bot_id) + REFERENCES marketplace_info (bot_id); diff --git a/backend/.cursor/rules/time-rule.mdc b/backend/.cursor/rules/time-rule.mdc new file mode 100644 index 0000000..df12f0f --- /dev/null +++ b/backend/.cursor/rules/time-rule.mdc @@ -0,0 +1,6 @@ +--- +description: +globs: +alwaysApply: true +--- +Always use time.Now().UTC() instead of time.Now() \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ecbd041 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,13 @@ +# docker-compose.yml +DB_NAME=postgresus +DB_USERNAME=postgres +DB_PASSWORD=Q1234567 +#app +ENV_MODE=development +# db +DATABASE_DSN=host=localhost user=postgres password=Q1234567 dbname=postgresus port=5437 sslmode=disable +DATABASE_URL=postgres://postgres:Q1234567@localhost:5437/postgresus?sslmode=disable +# migrations +GOOSE_DRIVER=postgres +GOOSE_DBSTRING=postgres://postgres:Q1234567@localhost:5437/postgresus?sslmode=disable +GOOSE_MIGRATION_DIR=./migrations \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..c267f00 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,12 @@ +main +.env +docker-compose.yml +pgdata +pgdata_test/ +main.exe +swagger/ +swagger/* +swagger/docs.go +swagger/swagger.json +swagger/swagger.yaml +postgresus-backend.exe \ No newline at end of file diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..5470d24 --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,19 @@ +version: "2" + +run: + timeout: 1m + tests: false + concurrency: 4 + +linters: + default: standard + + settings: + errcheck: + check-type-assertions: true + +formatters: + enable: + - gofmt + - golines + - goimports diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..cb1bc8f --- /dev/null +++ b/backend/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: local + hooks: + - id: golangci-lint-fmt + name: Format Go Code using golangci-lint fmt + entry: golangci-lint fmt ./... + language: system + types: [go] + + - id: golangci-lint-run + name: Run golangci-lint for static analysis + entry: golangci-lint run + language: system + types: [go] + pass_filenames: false diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..600ead7 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,44 @@ +FROM golang:1.23.3 + +EXPOSE 4005 + +WORKDIR /app + +# Install PostgreSQL APT repository and all versions 13-17 +RUN apt-get update && apt-get install -y wget ca-certificates gnupg lsb-release +RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list +RUN apt-get update + +# Install PostgreSQL client tools for versions 13-17 +RUN apt-get install -y \ + postgresql-client-13 \ + postgresql-client-14 \ + postgresql-client-15 \ + postgresql-client-16 \ + postgresql-client-17 + +# Create symlinks to match expected paths (/usr/pgsql-{VERSION}/bin) +RUN for version in 13 14 15 16 17; do \ + mkdir -p /usr/pgsql-${version}/bin; \ + ln -sf /usr/bin/pg_dump /usr/pgsql-${version}/bin/pg_dump; \ + ln -sf /usr/bin/psql /usr/pgsql-${version}/bin/psql; \ + ln -sf /usr/bin/pg_restore /usr/pgsql-${version}/bin/pg_restore; \ + ln -sf /usr/bin/createdb /usr/pgsql-${version}/bin/createdb; \ + ln -sf /usr/bin/dropdb /usr/pgsql-${version}/bin/dropdb; \ + done + +# Install global dependencies +RUN curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh +RUN goose -version +RUN go install github.com/swaggo/swag/cmd/swag@latest + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN swag init -d . -g cmd/main.go -o swagger +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main ./cmd/main.go + +CMD ["./main"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..2c66fa7 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,68 @@ +# Before run + +Keep in mind: you need to use dev-db from docker-compose.yml in this folder +instead of postgresus-db from docker-compose.yml in the root folder. + +> Copy .env.example to .env +> Copy docker-compose.yml.example to docker-compose.yml (for development only) + +# Run + +To build: + +> go build /cmd/main.go + +To run: + +> go run /cmd/main.go + +Before commit (make sure `golangci-lint` is installed): + +> golangci-lint fmt +> golangci-lint run + +# Migrations + +To create migration: + +> goose create MIGRATION_NAME sql + +To run migrations: + +> goose up + +If latest migration failed: + +To rollback on migration: + +> goose down + +# Swagger + +To generate swagger docs: + +> swag init -g .\cmd\main.go -o swagger + +Swagger URL is: + +> http://localhost:8080/api/v1/docs/swagger/index.html#/ + +# Project structure + +Default endpoint structure is: + +/feature +/feature/controller.go +/feature/service.go +/feature/repository.go +/feature/model.go +/feature/dto.go + +If there are couple of models: +/feature/models/model1.go +/feature/models/model2.go +... + +# Project rules + +Always use time.Now().UTC() instead of time.Now() diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..26ec163 --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "net/http" + + "postgresus-backend/internal/config" + "postgresus-backend/internal/downdetect" + "postgresus-backend/internal/features/backups" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/notifiers" + "postgresus-backend/internal/features/restores" + "postgresus-backend/internal/features/storages" + "postgresus-backend/internal/features/users" + "postgresus-backend/internal/storage" + env_utils "postgresus-backend/internal/util/env" + files_utils "postgresus-backend/internal/util/files" + "postgresus-backend/internal/util/logger" + _ "postgresus-backend/swagger" // swagger docs + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +// @title Postgresus Backend API +// @version 1.0 +// @description API for Postgresus +// @termsOfService http://swagger.io/terms/ + +// @host localhost:4005 +// @BasePath /api/v1 +// @schemes http +func main() { + log := logger.GetLogger() + + runMigrations(log) + + go generateSwaggerDocs(log) + + _ = storage.GetDb() + + gin.SetMode(gin.ReleaseMode) + ginApp := gin.Default() + + setUpRoutes(ginApp) + setUpDependencies() + runBackgroundTasks(log) + + startServerWithGracefulShutdown(log, ginApp) +} + +func startServerWithGracefulShutdown(log *slog.Logger, app *gin.Engine) { + host := "" + if config.GetEnv().EnvMode == env_utils.EnvModeDevelopment { + // for dev we use localhost to avoid firewall + // requests on each run for Windows + host = "127.0.0.1" + } + + srv := &http.Server{ + Addr: host + ":4005", + Handler: app, + } + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("listen:", "error", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + log.Info("Shutdown signal received") + + // The context is used to inform the server it has 10 seconds to finish + // the request it is currently handling + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Error("Server forced to shutdown:", "error", err) + } + + log.Info("Server gracefully stopped") +} + +func setUpRoutes(r *gin.Engine) { + v1 := r.Group("/api/v1") + + // Mount Swagger UI + v1.GET("/docs/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + downdetectContoller := downdetect.GetDowndetectController() + userController := users.GetUserController() + notifierController := notifiers.GetNotifierController() + storageController := storages.GetStorageController() + databaseController := databases.GetDatabaseController() + backupController := backups.GetBackupController() + restoreController := restores.GetRestoreController() + + downdetectContoller.RegisterRoutes(v1) + userController.RegisterRoutes(v1) + notifierController.RegisterRoutes(v1) + storageController.RegisterRoutes(v1) + databaseController.RegisterRoutes(v1) + backupController.RegisterRoutes(v1) + restoreController.RegisterRoutes(v1) +} + +func setUpDependencies() { + backups.SetupDependencies() +} + +func runBackgroundTasks(log *slog.Logger) { + log.Info("Preparing to run background tasks...") + + err := files_utils.CleanFolder(config.GetEnv().TempFolder) + if err != nil { + log.Error("Failed to clean temp folder", "error", err) + } + + go runWithPanicLogging(log, "backup background service", func() { + backups.GetBackupBackgroundService().Run() + }) + + go runWithPanicLogging(log, "restore background service", func() { + restores.GetRestoreBackgroundService().Run() + }) +} + +func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) { + defer func() { + if r := recover(); r != nil { + log.Error("Panic in "+serviceName, "error", r) + } + }() + fn() +} + +// Keep in mind: docs appear after second launch, because Swagger +// is generated into Go files. So if we changed files, we generate +// new docs, but still need to restart the server to see them. +func generateSwaggerDocs(log *slog.Logger) { + // Run swag from the current directory instead of parent + // Use the current directory as the base for swag init + // This ensures swag can find the files regardless of where the command is run from + currentDir, err := os.Getwd() + if err != nil { + log.Error("Failed to get current directory", "error", err) + return + } + + cmd := exec.Command("swag", "init", "-d", currentDir, "-g", "cmd/main.go", "-o", "swagger") + + output, err := cmd.CombinedOutput() + if err != nil { + log.Error("Failed to generate Swagger docs", "error", err, "output", string(output)) + return + } + + log.Info("Swagger documentation generated successfully") +} + +func runMigrations(log *slog.Logger) { + log.Info("Running database migrations...") + + cmd := exec.Command("goose", "up") + cmd.Env = append(os.Environ(), "GOOSE_DRIVER=postgres", "GOOSE_DBSTRING="+config.GetEnv().DatabaseDsn) + + // Set the working directory to where migrations are located + cmd.Dir = "./migrations" + + output, err := cmd.CombinedOutput() + if err != nil { + log.Error("Failed to run migrations", "error", err, "output", string(output)) + os.Exit(1) + } + + log.Info("Database migrations completed successfully", "output", string(output)) +} diff --git a/backend/docker-compose.yml.example b/backend/docker-compose.yml.example new file mode 100644 index 0000000..cc0f9c3 --- /dev/null +++ b/backend/docker-compose.yml.example @@ -0,0 +1,20 @@ +version: "3" + +services: + # For development only, because this DB + # exposes it's port to the public + dev-db: + env_file: + - .env + image: postgres:17 + ports: + - "5437:5437" + environment: + - POSTGRES_DB=${DEV_DB_NAME} + - POSTGRES_USER=${DEV_DB_USERNAME} + - POSTGRES_PASSWORD=${DEV_DB_PASSWORD} + volumes: + - ./pgdata:/var/lib/postgresql/data + container_name: dev-db + command: -p 5437 + shm_size: 10gb \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a542cbe --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,81 @@ +module postgresus-backend + +go 1.23.3 + +require ( + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/google/uuid v1.6.0 + github.com/pressly/goose/v3 v3.24.3 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/minio/crc64nvme v1.0.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.92 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/gin-swagger v1.6.0 // indirect + github.com/swaggo/swag v1.16.4 // indirect + github.com/tinylib/msgp v1.3.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.17.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/tools v0.22.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.5.11 // indirect + gorm.io/gorm v1.26.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..9d90fde --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,214 @@ +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= +github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw= +github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= +github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w= +github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= +golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= +gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..69fb8f6 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,120 @@ +package config + +import ( + "os" + "path/filepath" + env_utils "postgresus-backend/internal/util/env" + "postgresus-backend/internal/util/logger" + "postgresus-backend/internal/util/tools" + "strings" + "sync" + + "github.com/ilyakaznacheev/cleanenv" + "github.com/joho/godotenv" +) + +var log = logger.GetLogger() + +const ( + AppModeWeb = "web" + AppModeBackground = "background" +) + +type EnvVariables struct { + IsTesting bool + DatabaseDsn string `env:"DATABASE_DSN" required:"true"` + EnvMode env_utils.EnvMode `env:"ENV_MODE" required:"true"` + PostgresesInstallDir string `env:"POSTGRES_INSTALL_DIR"` + + DataFolder string + TempFolder string +} + +var ( + env EnvVariables + once sync.Once +) + +func GetEnv() EnvVariables { + once.Do(loadEnvVariables) + return env +} + +func loadEnvVariables() { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + log.Warn("could not get current working directory", "error", err) + cwd = "." + } + + projectRoot := cwd + for { + if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil { + break + } + + parent := filepath.Dir(projectRoot) + if parent == projectRoot { + break + } + + projectRoot = parent + } + + envPaths := []string{ + filepath.Join(cwd, ".env"), + filepath.Join(projectRoot, ".env"), + } + + var loaded bool + for _, path := range envPaths { + log.Info("Trying to load .env", "path", path) + if err := godotenv.Load(path); err == nil { + log.Info("Successfully loaded .env", "path", path) + loaded = true + break + } + } + + if !loaded { + log.Error("Error loading .env file: could not find .env in any location") + os.Exit(1) + } + + err = cleanenv.ReadEnv(&env) + if err != nil { + log.Error("Configuration could not be loaded", "error", err) + os.Exit(1) + } + + for _, arg := range os.Args { + if strings.Contains(arg, "test") { + env.IsTesting = true + break + } + } + + if env.DatabaseDsn == "" { + log.Error("DATABASE_DSN is empty") + os.Exit(1) + } + + if env.EnvMode == "" { + log.Error("ENV_MODE is empty") + os.Exit(1) + } + if env.EnvMode != "development" && env.EnvMode != "production" { + log.Error("ENV_MODE is invalid", "mode", env.EnvMode) + os.Exit(1) + } + log.Info("ENV_MODE loaded", "mode", env.EnvMode) + + env.PostgresesInstallDir = "./tools/postgresql" + tools.VerifyPostgresesInstallation(env.EnvMode, env.PostgresesInstallDir) + + env.DataFolder = "../postgresus-data/data" + env.TempFolder = "../postgresus-data/temp" + + log.Info("Environment variables loaded successfully!") +} diff --git a/backend/internal/config/signals.go b/backend/internal/config/signals.go new file mode 100644 index 0000000..64b89ea --- /dev/null +++ b/backend/internal/config/signals.go @@ -0,0 +1,23 @@ +package config + +import ( + "os" + "os/signal" + "syscall" +) + +var isShutDownSignalReceived = false + +func StartListeningForShutdownSignal() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + go func() { + <-quit + isShutDownSignalReceived = true + }() +} + +func IsShouldShutdown() bool { + return isShutDownSignalReceived +} diff --git a/backend/internal/downdetect/controller.go b/backend/internal/downdetect/controller.go new file mode 100644 index 0000000..9abddb6 --- /dev/null +++ b/backend/internal/downdetect/controller.go @@ -0,0 +1,37 @@ +package downdetect + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" +) + +type DowndetectController struct { + service *DowndetectService +} + +func (c *DowndetectController) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/downdetect/is-available", c.IsAvailable) +} + +// @Summary Check API availability +// @Description Checks if the API service is available +// @Tags downdetect +// @Accept json +// @Produce json +// @Success 200 +// @Failure 500 +// @Router /downdetect/api [get] +func (c *DowndetectController) IsAvailable(ctx *gin.Context) { + err := c.service.IsDbAvailable() + if err != nil { + ctx.JSON( + http.StatusInternalServerError, + gin.H{"error": fmt.Sprintf("Database is not available: %v", err)}, + ) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "API and DB are available"}) +} diff --git a/backend/internal/downdetect/di.go b/backend/internal/downdetect/di.go new file mode 100644 index 0000000..f11a15a --- /dev/null +++ b/backend/internal/downdetect/di.go @@ -0,0 +1,10 @@ +package downdetect + +var downdetectService = &DowndetectService{} +var downdetectController = &DowndetectController{ + downdetectService, +} + +func GetDowndetectController() *DowndetectController { + return downdetectController +} diff --git a/backend/internal/downdetect/service.go b/backend/internal/downdetect/service.go new file mode 100644 index 0000000..6b7a4cd --- /dev/null +++ b/backend/internal/downdetect/service.go @@ -0,0 +1,17 @@ +package downdetect + +import ( + "postgresus-backend/internal/storage" +) + +type DowndetectService struct { +} + +func (s *DowndetectService) IsDbAvailable() error { + err := storage.GetDb().Exec("SELECT 1").Error + if err != nil { + return err + } + + return nil +} diff --git a/backend/internal/features/backups/background_service.go b/backend/internal/features/backups/background_service.go new file mode 100644 index 0000000..9bc2545 --- /dev/null +++ b/backend/internal/features/backups/background_service.go @@ -0,0 +1,161 @@ +package backups + +import ( + "postgresus-backend/internal/config" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/util/logger" + "time" +) + +type BackupBackgroundService struct { + backupService *BackupService + backupRepository *BackupRepository + databaseService *databases.DatabaseService +} + +var log = logger.GetLogger() + +func (s *BackupBackgroundService) Run() { + if err := s.failBackupsInProgress(); err != nil { + log.Error("Failed to fail backups in progress", "error", err) + panic(err) + } + + if config.IsShouldShutdown() { + return + } + + for { + if config.IsShouldShutdown() { + return + } + + if err := s.cleanOldBackups(); err != nil { + log.Error("Failed to clean old backups", "error", err) + } + + if err := s.runPendingBackups(); err != nil { + log.Error("Failed to run pending backups", "error", err) + } + + time.Sleep(1 * time.Minute) + } +} + +func (s *BackupBackgroundService) failBackupsInProgress() error { + backupsInProgress, err := s.backupRepository.FindByStatus(BackupStatusInProgress) + if err != nil { + return err + } + + for _, backup := range backupsInProgress { + failMessage := "Backup failed due to application restart" + backup.FailMessage = &failMessage + backup.Status = BackupStatusFailed + backup.BackupSizeMb = 0 + + s.backupService.SendBackupNotification( + backup.Database, + backup, + databases.NotificationBackupFailed, + &failMessage, + ) + + if err := s.backupRepository.Save(backup); err != nil { + return err + } + } + + return nil +} + +func (s *BackupBackgroundService) cleanOldBackups() error { + allDatabases, err := s.databaseService.GetAllDatabases() + if err != nil { + return err + } + + for _, database := range allDatabases { + backupStorePeriod := database.StorePeriod + + if backupStorePeriod == databases.PeriodForever { + continue + } + + storeDuration := backupStorePeriod.ToDuration() + dateBeforeBackupsShouldBeDeleted := time.Now().UTC().Add(-storeDuration) + + oldBackups, err := s.backupRepository.FindBackupsBeforeDate( + database.ID, + dateBeforeBackupsShouldBeDeleted, + ) + if err != nil { + log.Error( + "Failed to find old backups for database", + "databaseId", + database.ID, + "error", + err, + ) + continue + } + + for _, backup := range oldBackups { + backup.DeleteBackupFromStorage() + + if err := s.backupRepository.DeleteByID(backup.ID); err != nil { + log.Error("Failed to delete old backup", "backupId", backup.ID, "error", err) + continue + } + + log.Info("Deleted old backup", "backupId", backup.ID, "databaseId", database.ID) + } + } + + return nil +} + +func (s *BackupBackgroundService) runPendingBackups() error { + allDatabases, err := s.databaseService.GetAllDatabases() + if err != nil { + return err + } + + for _, database := range allDatabases { + if database.BackupInterval == nil { + continue + } + + lastBackup, err := s.backupRepository.FindLastByDatabaseID(database.ID) + if err != nil { + log.Error( + "Failed to get last backup for database", + "databaseId", + database.ID, + "error", + err, + ) + continue + } + + var lastBackupTime *time.Time + if lastBackup != nil { + lastBackupTime = &lastBackup.CreatedAt + } + + if database.BackupInterval.ShouldTriggerBackup(time.Now().UTC(), lastBackupTime) { + log.Info( + "Triggering scheduled backup", + "databaseId", + database.ID, + "intervalType", + database.BackupInterval.Interval, + ) + + go s.backupService.MakeBackup(database.ID) + log.Info("Successfully triggered scheduled backup", "databaseId", database.ID) + } + } + + return nil +} diff --git a/backend/internal/features/backups/controller.go b/backend/internal/features/backups/controller.go new file mode 100644 index 0000000..9dbe763 --- /dev/null +++ b/backend/internal/features/backups/controller.go @@ -0,0 +1,145 @@ +package backups + +import ( + "net/http" + "postgresus-backend/internal/features/users" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type BackupController struct { + backupService *BackupService + userService *users.UserService +} + +func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/backups", c.GetBackups) + router.POST("/backups", c.MakeBackup) + router.DELETE("/backups/:id", c.DeleteBackup) +} + +// GetBackups +// @Summary Get backups for a database +// @Description Get all backups for the specified database +// @Tags backups +// @Produce json +// @Param database_id query string true "Database ID" +// @Success 200 {array} Backup +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /backups [get] +func (c *BackupController) GetBackups(ctx *gin.Context) { + databaseIDStr := ctx.Query("database_id") + if databaseIDStr == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "database_id query parameter is required"}) + return + } + + databaseID, err := uuid.Parse(databaseIDStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database_id"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + backups, err := c.backupService.GetBackups(user, databaseID) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, backups) +} + +// MakeBackup +// @Summary Create a backup +// @Description Create a new backup for the specified database +// @Tags backups +// @Accept json +// @Produce json +// @Param request body MakeBackupRequest true "Backup creation data" +// @Success 200 {object} map[string]string +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /backups [post] +func (c *BackupController) MakeBackup(ctx *gin.Context) { + var request MakeBackupRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if err := c.backupService.MakeBackupWithAuth(user, request.DatabaseID); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "backup started successfully"}) +} + +// DeleteBackup +// @Summary Delete a backup +// @Description Delete an existing backup +// @Tags backups +// @Param id path string true "Backup ID" +// @Success 204 +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /backups/{id} [delete] +func (c *BackupController) DeleteBackup(ctx *gin.Context) { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if err := c.backupService.DeleteBackup(user, id); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.Status(http.StatusNoContent) +} + +type MakeBackupRequest struct { + DatabaseID uuid.UUID `json:"database_id" binding:"required"` +} diff --git a/backend/internal/features/backups/di.go b/backend/internal/features/backups/di.go new file mode 100644 index 0000000..1c1d3b5 --- /dev/null +++ b/backend/internal/features/backups/di.go @@ -0,0 +1,52 @@ +package backups + +import ( + "postgresus-backend/internal/features/backups/usecases" + usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/notifiers" + "postgresus-backend/internal/features/storages" + "postgresus-backend/internal/features/users" +) + +var createPostgresqlBackupUsecase = &usecases_postgresql.CreatePostgresqlBackupUsecase{} +var createBackupUseCase = &usecases.CreateBackupUsecase{ + CreatePostgresqlBackupUsecase: createPostgresqlBackupUsecase, +} +var backupRepository = &BackupRepository{} +var backupService = &BackupService{ + databases.GetDatabaseService(), + storages.GetStorageService(), + backupRepository, + notifiers.GetNotifierService(), + createBackupUseCase, +} + +var backupBackgroundService = &BackupBackgroundService{ + backupService, + backupRepository, + databases.GetDatabaseService(), +} + +var backupController = &BackupController{ + backupService, + users.GetUserService(), +} + +func SetupDependencies() { + databases. + GetDatabaseService(). + SetDatabaseStorageChangeListener(backupService) +} + +func GetBackupService() *BackupService { + return backupService +} + +func GetBackupController() *BackupController { + return backupController +} + +func GetBackupBackgroundService() *BackupBackgroundService { + return backupBackgroundService +} diff --git a/backend/internal/features/backups/enums.go b/backend/internal/features/backups/enums.go new file mode 100644 index 0000000..3c1cb4a --- /dev/null +++ b/backend/internal/features/backups/enums.go @@ -0,0 +1,10 @@ +package backups + +type BackupStatus string + +const ( + BackupStatusInProgress BackupStatus = "IN_PROGRESS" + BackupStatusCompleted BackupStatus = "COMPLETED" + BackupStatusFailed BackupStatus = "FAILED" + BackupStatusDeleted BackupStatus = "DELETED" +) diff --git a/backend/internal/features/backups/model.go b/backend/internal/features/backups/model.go new file mode 100644 index 0000000..64da0d6 --- /dev/null +++ b/backend/internal/features/backups/model.go @@ -0,0 +1,41 @@ +package backups + +import ( + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/storages" + "time" + + "github.com/google/uuid" +) + +type Backup struct { + ID uuid.UUID `json:"id" gorm:"column:id;type:uuid;primaryKey"` + + Database *databases.Database `json:"database" gorm:"foreignKey:DatabaseID"` + DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;not null"` + + Storage *storages.Storage `json:"storage" gorm:"foreignKey:StorageID"` + StorageID uuid.UUID `json:"storageId" gorm:"column:storage_id;type:uuid;not null"` + + Status BackupStatus `json:"status" gorm:"column:status;not null"` + FailMessage *string `json:"failMessage" gorm:"column:fail_message"` + + BackupSizeMb float64 `json:"backupSizeMb" gorm:"column:backup_size_mb;default:0"` + + BackupDurationMs int64 `json:"backupDurationMs" gorm:"column:backup_duration_ms;default:0"` + + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` +} + +func (b *Backup) DeleteBackupFromStorage() { + if b.Status != BackupStatusCompleted { + return + } + + err := b.Storage.DeleteFile(b.ID) + if err != nil { + log.Error("Failed to delete backup from storage", "error", err) + // we ignore the error, because the access to the storage + // may be lost, file already deleted, etc. + } +} diff --git a/backend/internal/features/backups/repository.go b/backend/internal/features/backups/repository.go new file mode 100644 index 0000000..c9bb0a1 --- /dev/null +++ b/backend/internal/features/backups/repository.go @@ -0,0 +1,137 @@ +package backups + +import ( + "postgresus-backend/internal/storage" + + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type BackupRepository struct{} + +func (r *BackupRepository) Save(backup *Backup) error { + db := storage.GetDb() + + isNew := backup.ID == uuid.Nil + if isNew { + backup.ID = uuid.New() + return db.Create(backup). + Omit("Database", "Storage"). + Error + } + + return db.Save(backup). + Omit("Database", "Storage"). + Error +} + +func (r *BackupRepository) FindByDatabaseID(databaseID uuid.UUID) ([]*Backup, error) { + var backups []*Backup + + if err := storage. + GetDb(). + Preload("Database"). + Preload("Storage"). + Where("database_id = ?", databaseID). + Order("created_at DESC"). + Find(&backups).Error; err != nil { + return nil, err + } + + return backups, nil +} + +func (r *BackupRepository) FindLastByDatabaseID(databaseID uuid.UUID) (*Backup, error) { + var backup Backup + + if err := storage. + GetDb(). + Preload("Database"). + Preload("Storage"). + Where("database_id = ?", databaseID). + Order("created_at DESC"). + First(&backup).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + + return nil, err + } + + return &backup, nil +} + +func (r *BackupRepository) FindByID(id uuid.UUID) (*Backup, error) { + var backup Backup + + if err := storage. + GetDb(). + Preload("Database"). + Preload("Storage"). + Where("id = ?", id). + First(&backup).Error; err != nil { + return nil, err + } + + return &backup, nil +} + +func (r *BackupRepository) FindByStatus(status BackupStatus) ([]*Backup, error) { + var backups []*Backup + + if err := storage. + GetDb(). + Preload("Database"). + Preload("Storage"). + Where("status = ?", status). + Order("created_at DESC"). + Find(&backups).Error; err != nil { + return nil, err + } + + return backups, nil +} + +func (r *BackupRepository) FindByStorageIdAndStatus( + storageID uuid.UUID, + status BackupStatus, +) ([]*Backup, error) { + var backups []*Backup + + if err := storage. + GetDb(). + Preload("Database"). + Preload("Storage"). + Where("storage_id = ? AND status = ?", storageID, status). + Order("created_at DESC"). + Find(&backups).Error; err != nil { + return nil, err + } + + return backups, nil +} + +func (r *BackupRepository) DeleteByID(id uuid.UUID) error { + return storage.GetDb().Delete(&Backup{}, "id = ?", id).Error +} + +func (r *BackupRepository) FindBackupsBeforeDate( + databaseID uuid.UUID, + date time.Time, +) ([]*Backup, error) { + var backups []*Backup + + if err := storage. + GetDb(). + Preload("Database"). + Preload("Storage"). + Where("database_id = ? AND created_at < ?", databaseID, date). + Order("created_at DESC"). + Find(&backups).Error; err != nil { + return nil, err + } + + return backups, nil +} diff --git a/backend/internal/features/backups/service.go b/backend/internal/features/backups/service.go new file mode 100644 index 0000000..f212f4b --- /dev/null +++ b/backend/internal/features/backups/service.go @@ -0,0 +1,319 @@ +package backups + +import ( + "errors" + "fmt" + "postgresus-backend/internal/features/backups/usecases" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/notifiers" + "postgresus-backend/internal/features/storages" + users_models "postgresus-backend/internal/features/users/models" + "slices" + "time" + + "github.com/google/uuid" +) + +type BackupService struct { + databaseService *databases.DatabaseService + storageService *storages.StorageService + backupRepository *BackupRepository + notifierService *notifiers.NotifierService + + createBackupUseCase *usecases.CreateBackupUsecase +} + +func (s *BackupService) OnBeforeDbStorageChange( + databaseID uuid.UUID, + storageID uuid.UUID, +) error { + // validate no backups in progress + backups, err := s.backupRepository.FindByStorageIdAndStatus( + storageID, + BackupStatusInProgress, + ) + if err != nil { + return err + } + + if len(backups) > 0 { + return errors.New("backup is in progress, storage cannot") + } + + backupsWithStorage, err := s.backupRepository.FindByStorageIdAndStatus( + storageID, + BackupStatusCompleted, + ) + if err != nil { + return err + } + + if len(backupsWithStorage) > 0 { + for _, backup := range backupsWithStorage { + if err := backup.Storage.DeleteFile(backup.ID); err != nil { + // most likely we cannot do nothing with this, + // so we just remove the backup model + log.Error("Failed to delete backup file", "error", err) + } + + if err := s.backupRepository.DeleteByID(backup.ID); err != nil { + return err + } + } + + // we repeat remove for the case if backup + // started until we removed all previous backups + return s.OnBeforeDbStorageChange(databaseID, storageID) + } + + return nil +} + +func (s *BackupService) MakeBackupWithAuth( + user *users_models.User, + databaseID uuid.UUID, +) error { + database, err := s.databaseService.GetDatabaseByID(databaseID) + if err != nil { + return err + } + + if database.UserID != user.ID { + return errors.New("user does not have access to this database") + } + + go s.MakeBackup(databaseID) + + return nil +} + +func (s *BackupService) GetBackups( + user *users_models.User, + databaseID uuid.UUID, +) ([]*Backup, error) { + database, err := s.databaseService.GetDatabaseByID(databaseID) + if err != nil { + return nil, err + } + + if database.UserID != user.ID { + return nil, errors.New("user does not have access to this database") + } + + backups, err := s.backupRepository.FindByDatabaseID(databaseID) + if err != nil { + return nil, err + } + + return backups, nil +} + +func (s *BackupService) DeleteBackup( + user *users_models.User, + backupID uuid.UUID, +) error { + backup, err := s.backupRepository.FindByID(backupID) + if err != nil { + return err + } + + if backup.Database.UserID != user.ID { + return errors.New("user does not have access to this backup") + } + + if backup.Status == BackupStatusInProgress { + return errors.New("backup is in progress") + } + + backup.DeleteBackupFromStorage() + + backup.Status = BackupStatusDeleted + return s.backupRepository.Save(backup) +} + +func (s *BackupService) MakeBackup(databaseID uuid.UUID) { + database, err := s.databaseService.GetDatabaseByID(databaseID) + if err != nil { + log.Error("Failed to get database by ID", "error", err) + return + } + + lastBackup, err := s.backupRepository.FindLastByDatabaseID(databaseID) + if err != nil { + log.Error("Failed to find last backup by database ID", "error", err) + return + } + + if lastBackup != nil && lastBackup.Status == BackupStatusInProgress { + log.Error("Backup is in progress") + return + } + + storage, err := s.storageService.GetStorageByID(database.StorageID) + if err != nil { + log.Error("Failed to get storage by ID", "error", err) + return + } + + backup := &Backup{ + DatabaseID: databaseID, + Database: database, + + StorageID: storage.ID, + Storage: storage, + + Status: BackupStatusInProgress, + + BackupSizeMb: 0, + + CreatedAt: time.Now().UTC(), + } + + if err := s.backupRepository.Save(backup); err != nil { + log.Error("Failed to save backup", "error", err) + return + } + + start := time.Now().UTC() + + backupProgressListener := func( + completedMBs float64, + ) { + backup.BackupSizeMb = completedMBs + backup.BackupDurationMs = time.Since(start).Milliseconds() + + if err := s.backupRepository.Save(backup); err != nil { + log.Error("Failed to update backup progress", "error", err) + } + } + + err = s.createBackupUseCase.Execute( + backup.ID, + database, + storage, + backupProgressListener, + ) + if err != nil { + errMsg := err.Error() + backup.FailMessage = &errMsg + backup.Status = BackupStatusFailed + backup.BackupDurationMs = time.Since(start).Milliseconds() + backup.BackupSizeMb = 0 + + if updateErr := s.databaseService.SetBackupError(databaseID, errMsg); updateErr != nil { + log.Error( + "Failed to update database last backup time", + "databaseId", + databaseID, + "error", + updateErr, + ) + } + + if err := s.backupRepository.Save(backup); err != nil { + log.Error("Failed to save backup", "error", err) + } + + s.SendBackupNotification( + database, + backup, + databases.NotificationBackupFailed, + &errMsg, + ) + + return + } + + backup.Status = BackupStatusCompleted + backup.BackupDurationMs = time.Since(start).Milliseconds() + + if err := s.backupRepository.Save(backup); err != nil { + log.Error("Failed to save backup", "error", err) + return + } + + // Update database last backup time + now := time.Now().UTC() + if updateErr := s.databaseService.SetLastBackupTime(databaseID, now); updateErr != nil { + log.Error( + "Failed to update database last backup time", + "databaseId", + databaseID, + "error", + updateErr, + ) + } + + s.SendBackupNotification( + database, + backup, + databases.NotificationBackupSuccess, + nil, + ) +} + +func (s *BackupService) SendBackupNotification( + db *databases.Database, + backup *Backup, + notificationType databases.BackupNotificationType, + errorMessage *string, +) { + database, err := s.databaseService.GetDatabaseByID(db.ID) + if err != nil { + return + } + + for _, notifier := range database.Notifiers { + if !slices.Contains( + database.SendNotificationsOn, + notificationType, + ) { + continue + } + + title := "" + switch notificationType { + case databases.NotificationBackupFailed: + title = fmt.Sprintf("❌ Backup failed for database \"%s\"", database.Name) + case databases.NotificationBackupSuccess: + title = fmt.Sprintf("✅ Backup completed for database \"%s\"", database.Name) + } + + message := "" + if errorMessage != nil { + message = *errorMessage + } else { + // Format size conditionally + var sizeStr string + if backup.BackupSizeMb < 1024 { + sizeStr = fmt.Sprintf("%.2f MB", backup.BackupSizeMb) + } else { + sizeGB := backup.BackupSizeMb / 1024 + sizeStr = fmt.Sprintf("%.2f GB", sizeGB) + } + + // Format duration as "0m 0s 0ms" + totalMs := backup.BackupDurationMs + minutes := totalMs / (1000 * 60) + seconds := (totalMs % (1000 * 60)) / 1000 + milliseconds := totalMs % 1000 + durationStr := fmt.Sprintf("%dm %ds %dms", minutes, seconds, milliseconds) + + message = fmt.Sprintf( + "Backup completed successfully in %s.\nCompressed backup size: %s", + durationStr, + sizeStr, + ) + } + + s.notifierService.SendNotification( + ¬ifier, + title, + message, + ) + } +} + +func (s *BackupService) GetBackup(backupID uuid.UUID) (*Backup, error) { + return s.backupRepository.FindByID(backupID) +} diff --git a/backend/internal/features/backups/usecases/create_backup_uc.go b/backend/internal/features/backups/usecases/create_backup_uc.go new file mode 100644 index 0000000..b75fe5d --- /dev/null +++ b/backend/internal/features/backups/usecases/create_backup_uc.go @@ -0,0 +1,35 @@ +package usecases + +import ( + "errors" + usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/storages" + + "github.com/google/uuid" +) + +type CreateBackupUsecase struct { + CreatePostgresqlBackupUsecase *usecases_postgresql.CreatePostgresqlBackupUsecase +} + +// Execute creates a backup of the database and returns the backup size in MB +func (uc *CreateBackupUsecase) Execute( + backupID uuid.UUID, + database *databases.Database, + storage *storages.Storage, + backupProgressListener func( + completedMBs float64, + ), +) error { + if database.Type == databases.DatabaseTypePostgres { + return uc.CreatePostgresqlBackupUsecase.Execute( + backupID, + database, + storage, + backupProgressListener, + ) + } + + return errors.New("database type not supported") +} diff --git a/backend/internal/features/backups/usecases/postgresql/create_backup_uc.go b/backend/internal/features/backups/usecases/postgresql/create_backup_uc.go new file mode 100644 index 0000000..f943147 --- /dev/null +++ b/backend/internal/features/backups/usecases/postgresql/create_backup_uc.go @@ -0,0 +1,499 @@ +package usecases_postgresql + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "postgresus-backend/internal/config" + "postgresus-backend/internal/features/databases" + pgtypes "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/features/storages" + "postgresus-backend/internal/util/logger" + "postgresus-backend/internal/util/tools" + + "github.com/google/uuid" +) + +var log = logger.GetLogger() + +type CreatePostgresqlBackupUsecase struct{} + +// Execute creates a backup of the database +func (uc *CreatePostgresqlBackupUsecase) Execute( + backupID uuid.UUID, + db *databases.Database, + storage *storages.Storage, + backupProgressListener func( + completedMBs float64, + ), +) error { + log.Info( + "Creating PostgreSQL backup via pg_dump custom format", + "databaseId", + db.ID, + "storageId", + storage.ID, + ) + + pg := db.Postgresql + if pg.Database == nil || *pg.Database == "" { + return fmt.Errorf("database name is required for pg_dump backups") + } + + args := []string{ + "-Fc", // custom format with built-in compression + "-Z", "6", // balanced compression level (0-9, 6 is balanced) + "--no-password", // Use environment variable for password, prevent prompts + "-h", pg.Host, + "-p", strconv.Itoa(pg.Port), + "-U", pg.Username, + "-d", *pg.Database, + "--verbose", // Add verbose output to help with debugging + } + + return uc.streamToStorage( + backupID, + tools.GetPostgresqlExecutable( + pg.Version, + "pg_dump", + config.GetEnv().EnvMode, + config.GetEnv().PostgresesInstallDir, + ), + args, + pg.Password, + storage, + db, + backupProgressListener, + ) +} + +// streamToStorage streams pg_dump output directly to storage +func (uc *CreatePostgresqlBackupUsecase) streamToStorage( + backupID uuid.UUID, + pgBin string, + args []string, + password string, + storage *storages.Storage, + db *databases.Database, + backupProgressListener func(completedMBs float64), +) error { + log.Info("Streaming PostgreSQL backup to storage", "pgBin", pgBin, "args", args) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) + defer cancel() + + // Monitor for shutdown and cancel context if needed + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if config.IsShouldShutdown() { + cancel() + return + } + } + } + }() + + // Create temporary .pgpass file as a more reliable alternative to PGPASSWORD + pgpassFile, err := uc.createTempPgpassFile(db.Postgresql, password) + if err != nil { + return fmt.Errorf("failed to create temporary .pgpass file: %w", err) + } + defer func() { + if pgpassFile != "" { + _ = os.Remove(pgpassFile) + } + }() + + // Verify .pgpass file was created successfully + if pgpassFile == "" { + return fmt.Errorf("temporary .pgpass file was not created") + } + + // Verify .pgpass file was created correctly + if info, err := os.Stat(pgpassFile); err == nil { + log.Info("Temporary .pgpass file created successfully", + "pgpassFile", pgpassFile, + "size", info.Size(), + "mode", info.Mode(), + ) + } else { + return fmt.Errorf("failed to verify .pgpass file: %w", err) + } + + cmd := exec.CommandContext(ctx, pgBin, args...) + log.Info("Executing PostgreSQL backup command", "command", cmd.String()) + + // Start with system environment variables to preserve Windows PATH, SystemRoot, etc. + cmd.Env = os.Environ() + + // Use the .pgpass file for authentication + cmd.Env = append(cmd.Env, "PGPASSFILE="+pgpassFile) + log.Info("Using temporary .pgpass file for authentication", "pgpassFile", pgpassFile) + + // Debug password setup (without exposing the actual password) + log.Info("Setting up PostgreSQL environment", + "passwordLength", len(password), + "passwordEmpty", password == "", + "pgBin", pgBin, + "usingPgpassFile", true, + "parallelJobs", db.Postgresql.CpuCount, + ) + + // Add PostgreSQL-specific environment variables + cmd.Env = append(cmd.Env, "PGCLIENTENCODING=UTF8") + cmd.Env = append(cmd.Env, "PGCONNECT_TIMEOUT=30") + + // Add encoding-related environment variables to handle character encoding issues + cmd.Env = append(cmd.Env, "LC_ALL=C.UTF-8") + cmd.Env = append(cmd.Env, "LANG=C.UTF-8") + + // Add PostgreSQL-specific encoding settings + cmd.Env = append(cmd.Env, "PGOPTIONS=--client-encoding=UTF8") + + shouldRequireSSL := db.Postgresql.IsHttps + + // Require SSL when explicitly configured + if shouldRequireSSL { + cmd.Env = append(cmd.Env, "PGSSLMODE=require") + log.Info("Using required SSL mode", "configuredHttps", db.Postgresql.IsHttps) + } else { + // SSL not explicitly required, but prefer it if available + cmd.Env = append(cmd.Env, "PGSSLMODE=prefer") + log.Info("Using preferred SSL mode", "configuredHttps", db.Postgresql.IsHttps) + } + + // Set other SSL parameters to avoid certificate issues + cmd.Env = append(cmd.Env, "PGSSLCERT=") // No client certificate + cmd.Env = append(cmd.Env, "PGSSLKEY=") // No client key + cmd.Env = append(cmd.Env, "PGSSLROOTCERT=") // No root certificate verification + cmd.Env = append(cmd.Env, "PGSSLCRL=") // No certificate revocation list + + // Verify executable exists and is accessible + if _, err := exec.LookPath(pgBin); err != nil { + return fmt.Errorf( + "PostgreSQL executable not found or not accessible: %s - %w", + pgBin, + err, + ) + } + + pgStdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + + pgStderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + + // Capture stderr in a separate goroutine to ensure we don't miss any error output + stderrCh := make(chan []byte, 1) + go func() { + stderrOutput, _ := io.ReadAll(pgStderr) + stderrCh <- stderrOutput + }() + + // A pipe connecting pg_dump output → storage + storageReader, storageWriter := io.Pipe() + + // Create a counting writer to track bytes + countingWriter := &CountingWriter{writer: storageWriter} + + // The backup ID becomes the object key / filename in storage + + // Start streaming into storage in its own goroutine + saveErrCh := make(chan error, 1) + go func() { + saveErrCh <- storage.SaveFile(backupID, storageReader) + }() + + // Start pg_dump + if err = cmd.Start(); err != nil { + return fmt.Errorf("start %s: %w", filepath.Base(pgBin), err) + } + + // Copy pg output directly to storage with shutdown checks + copyResultCh := make(chan error, 1) + bytesWrittenCh := make(chan int64, 1) + go func() { + bytesWritten, err := uc.copyWithShutdownCheck( + ctx, + countingWriter, + pgStdout, + backupProgressListener, + ) + bytesWrittenCh <- bytesWritten + copyResultCh <- err + }() + + // Wait for the dump and copy to finish + waitErr := cmd.Wait() + copyErr := <-copyResultCh + bytesWritten := <-bytesWrittenCh + + // Check for shutdown before finalizing + if config.IsShouldShutdown() { + if pipeWriter, ok := countingWriter.writer.(*io.PipeWriter); ok { + if err := pipeWriter.Close(); err != nil { + log.Error("Failed to close counting writer", "error", err) + } + } + + <-saveErrCh // Wait for storage to finish + return fmt.Errorf("backup cancelled due to shutdown") + } + + // Close the pipe writer to signal end of data + if pipeWriter, ok := countingWriter.writer.(*io.PipeWriter); ok { + if err := pipeWriter.Close(); err != nil { + log.Error("Failed to close counting writer", "error", err) + } + } + + // Wait until storage ends reading + saveErr := <-saveErrCh + stderrOutput := <-stderrCh + + // Send final sizing after backup is completed + if waitErr == nil && copyErr == nil && saveErr == nil && backupProgressListener != nil { + sizeMB := float64(bytesWritten) / (1024 * 1024) + backupProgressListener(sizeMB) + } + + switch { + case waitErr != nil: + if config.IsShouldShutdown() { + return fmt.Errorf("backup cancelled due to shutdown") + } + + // Enhanced error handling for PostgreSQL connection and SSL issues + stderrStr := string(stderrOutput) + errorMsg := fmt.Sprintf( + "%s failed: %v – stderr: %s", + filepath.Base(pgBin), + waitErr, + stderrStr, + ) + + // Check for specific PostgreSQL error patterns + if exitErr, ok := waitErr.(*exec.ExitError); ok { + exitCode := exitErr.ExitCode() + + // Enhanced debugging for exit status 1 with empty stderr + if exitCode == 1 && strings.TrimSpace(stderrStr) == "" { + log.Error("pg_dump failed with exit status 1 but no stderr output", + "pgBin", pgBin, + "args", args, + "env_vars", []string{ + "PGCLIENTENCODING=UTF8", + "PGCONNECT_TIMEOUT=30", + "LC_ALL=C.UTF-8", + "LANG=C.UTF-8", + "PGOPTIONS=--client-encoding=UTF8", + }, + ) + + errorMsg = fmt.Sprintf( + "%s failed with exit status 1 but provided no error details. "+ + "This often indicates: "+ + "1) Connection timeout or refused connection, "+ + "2) Authentication failure with incorrect credentials, "+ + "3) Database does not exist, "+ + "4) Network connectivity issues, "+ + "5) PostgreSQL server not running. "+ + "Command executed: %s %s", + filepath.Base(pgBin), + pgBin, + strings.Join(args, " "), + ) + } else if exitCode == -1073741819 { // 0xC0000005 in decimal + log.Error("PostgreSQL tool crashed with access violation", + "pgBin", pgBin, + "args", args, + "exitCode", fmt.Sprintf("0x%X", uint32(exitCode)), + ) + + errorMsg = fmt.Sprintf( + "%s crashed with access violation (0xC0000005). This may indicate incompatible PostgreSQL version, corrupted installation, or connection issues. stderr: %s", + filepath.Base(pgBin), + stderrStr, + ) + } else if exitCode == 1 || exitCode == 2 { + // Check for common connection and authentication issues + if containsIgnoreCase(stderrStr, "pg_hba.conf") { + errorMsg = fmt.Sprintf( + "PostgreSQL connection rejected by server configuration (pg_hba.conf). The server may not allow connections from your IP address or may require different authentication settings. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "no password supplied") || containsIgnoreCase(stderrStr, "fe_sendauth") { + errorMsg = fmt.Sprintf( + "PostgreSQL authentication failed - no password supplied. "+ + "PGPASSWORD environment variable may not be working correctly on this system. "+ + "Password length: %d, Password empty: %v. "+ + "Consider using a .pgpass file as an alternative. stderr: %s", + len(password), + password == "", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "ssl") && containsIgnoreCase(stderrStr, "connection") { + errorMsg = fmt.Sprintf( + "PostgreSQL SSL connection failed. The server may require SSL encryption or have SSL configuration issues. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "connection") && containsIgnoreCase(stderrStr, "refused") { + errorMsg = fmt.Sprintf( + "PostgreSQL connection refused. Check if the server is running and accessible from your network. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "authentication") || containsIgnoreCase(stderrStr, "password") { + errorMsg = fmt.Sprintf( + "PostgreSQL authentication failed. Check username and password. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "timeout") { + errorMsg = fmt.Sprintf( + "PostgreSQL connection timeout. The server may be unreachable or overloaded. stderr: %s", + stderrStr, + ) + } + } + } + + return errors.New(errorMsg) + case copyErr != nil: + if config.IsShouldShutdown() { + return fmt.Errorf("backup cancelled due to shutdown") + } + + return fmt.Errorf("copy to storage: %w", copyErr) + case saveErr != nil: + if config.IsShouldShutdown() { + return fmt.Errorf("backup cancelled due to shutdown") + } + + return fmt.Errorf("save to storage: %w", saveErr) + } + + return nil +} + +// copyWithShutdownCheck copies data from src to dst while checking for shutdown +func (uc *CreatePostgresqlBackupUsecase) copyWithShutdownCheck( + ctx context.Context, + dst io.Writer, + src io.Reader, + backupProgressListener func(completedMBs float64), +) (int64, error) { + buf := make([]byte, 32*1024) // 32KB buffer + var totalBytesWritten int64 + + // Progress reporting interval - report every 1MB of data + var lastReportedMB float64 + const reportIntervalMB = 1.0 + + for { + select { + case <-ctx.Done(): + return totalBytesWritten, fmt.Errorf("copy cancelled: %w", ctx.Err()) + default: + } + + if config.IsShouldShutdown() { + return totalBytesWritten, fmt.Errorf("copy cancelled due to shutdown") + } + + bytesRead, readErr := src.Read(buf) + if bytesRead > 0 { + bytesWritten, writeErr := dst.Write(buf[0:bytesRead]) + if bytesWritten < 0 || bytesRead < bytesWritten { + bytesWritten = 0 + if writeErr == nil { + writeErr = fmt.Errorf("invalid write result") + } + } + + if writeErr != nil { + return totalBytesWritten, writeErr + } + + if bytesRead != bytesWritten { + return totalBytesWritten, io.ErrShortWrite + } + + totalBytesWritten += int64(bytesWritten) + + // Report progress based on total size + if backupProgressListener != nil { + currentSizeMB := float64(totalBytesWritten) / (1024 * 1024) + + // Only report if we've written at least 1MB more data than last report + if currentSizeMB >= lastReportedMB+reportIntervalMB { + backupProgressListener(currentSizeMB) + lastReportedMB = currentSizeMB + } + } + } + + if readErr != nil { + if readErr != io.EOF { + return totalBytesWritten, readErr + } + + break + } + } + + return totalBytesWritten, nil +} + +// containsIgnoreCase checks if a string contains a substring, ignoring case +func containsIgnoreCase(str, substr string) bool { + return strings.Contains(strings.ToLower(str), strings.ToLower(substr)) +} + +// createTempPgpassFile creates a temporary .pgpass file with the given password +func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile( + pgConfig *pgtypes.PostgresqlDatabase, + password string, +) (string, error) { + if pgConfig == nil || password == "" { + return "", nil + } + + pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s", + pgConfig.Host, + pgConfig.Port, + pgConfig.Username, + password, + ) + + // it always create unique directory like /tmp/pgpass-1234567890 + tempDir, err := os.MkdirTemp("", "pgpass") + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + + pgpassFile := filepath.Join(tempDir, ".pgpass") + err = os.WriteFile(pgpassFile, []byte(pgpassContent), 0600) + if err != nil { + return "", fmt.Errorf("failed to write temporary .pgpass file: %w", err) + } + + return pgpassFile, nil +} diff --git a/backend/internal/features/backups/usecases/postgresql/interfaces.go b/backend/internal/features/backups/usecases/postgresql/interfaces.go new file mode 100644 index 0000000..0e9ab18 --- /dev/null +++ b/backend/internal/features/backups/usecases/postgresql/interfaces.go @@ -0,0 +1,20 @@ +package usecases_postgresql + +import "io" + +// CountingWriter wraps an io.Writer and counts the bytes written to it +type CountingWriter struct { + writer io.Writer + bytesWritten int64 +} + +func (cw *CountingWriter) Write(p []byte) (n int, err error) { + n, err = cw.writer.Write(p) + cw.bytesWritten += int64(n) + return n, err +} + +// GetBytesWritten returns the total number of bytes written +func (cw *CountingWriter) GetBytesWritten() int64 { + return cw.bytesWritten +} diff --git a/backend/internal/features/databases/controller.go b/backend/internal/features/databases/controller.go new file mode 100644 index 0000000..61da44f --- /dev/null +++ b/backend/internal/features/databases/controller.go @@ -0,0 +1,365 @@ +package databases + +import ( + "net/http" + "postgresus-backend/internal/features/users" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type DatabaseController struct { + databaseService *DatabaseService + userService *users.UserService +} + +func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) { + router.POST("/databases/create", c.CreateDatabase) + router.POST("/databases/update", c.UpdateDatabase) + router.DELETE("/databases/:id", c.DeleteDatabase) + router.GET("/databases/:id", c.GetDatabase) + router.GET("/databases", c.GetDatabases) + router.POST("/databases/:id/test-connection", c.TestDatabaseConnection) + router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect) + router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing) + router.GET("/databases/storage/:id/is-using", c.IsStorageUsing) +} + +// CreateDatabase +// @Summary Create a new database +// @Description Create a new database configuration +// @Tags databases +// @Accept json +// @Produce json +// @Param request body Database true "Database creation data" +// @Success 201 {object} Database +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /databases/create [post] +func (c *DatabaseController) CreateDatabase(ctx *gin.Context) { + var request Database + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if err := c.databaseService.CreateDatabase(user, &request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, request) +} + +// UpdateDatabase +// @Summary Update a database +// @Description Update an existing database configuration +// @Tags databases +// @Accept json +// @Produce json +// @Param request body Database true "Database update data" +// @Success 200 {object} Database +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /databases/update [post] +func (c *DatabaseController) UpdateDatabase(ctx *gin.Context) { + var request Database + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if err := c.databaseService.UpdateDatabase(user, &request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, request) +} + +// DeleteDatabase +// @Summary Delete a database +// @Description Delete a database configuration +// @Tags databases +// @Param id path string true "Database ID" +// @Success 204 +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /databases/{id} [delete] +func (c *DatabaseController) DeleteDatabase(ctx *gin.Context) { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if err := c.databaseService.DeleteDatabase(user, id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.Status(http.StatusNoContent) +} + +// GetDatabase +// @Summary Get a database +// @Description Get a database configuration by ID +// @Tags databases +// @Produce json +// @Param id path string true "Database ID" +// @Success 200 {object} Database +// @Failure 400 +// @Failure 401 +// @Router /databases/{id} [get] +func (c *DatabaseController) GetDatabase(ctx *gin.Context) { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + database, err := c.databaseService.GetDatabase(user, id) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, database) +} + +// GetDatabases +// @Summary Get databases +// @Description Get all databases for the authenticated user +// @Tags databases +// @Produce json +// @Success 200 {array} Database +// @Failure 401 +// @Failure 500 +// @Router /databases [get] +func (c *DatabaseController) GetDatabases(ctx *gin.Context) { + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + databases, err := c.databaseService.GetDatabasesByUser(user) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, databases) +} + +// TestDatabaseConnection +// @Summary Test database connection +// @Description Test connection to an existing database configuration +// @Tags databases +// @Param id path string true "Database ID" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /databases/{id}/test-connection [post] +func (c *DatabaseController) TestDatabaseConnection(ctx *gin.Context) { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if err := c.databaseService.TestDatabaseConnection(user, id); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "connection successful"}) +} + +// TestDatabaseConnectionDirect +// @Summary Test database connection directly +// @Description Test connection to a database configuration without saving it +// @Tags databases +// @Accept json +// @Param request body Database true "Database configuration to test" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Router /databases/test-connection-direct [post] +func (c *DatabaseController) TestDatabaseConnectionDirect(ctx *gin.Context) { + var request Database + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + // Set user ID for validation purposes + request.UserID = user.ID + + if err := c.databaseService.TestDatabaseConnectionDirect(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "connection successful"}) +} + +// IsNotifierUsing +// @Summary Check if notifier is being used +// @Description Check if a notifier is currently being used by any database +// @Tags databases +// @Produce json +// @Param id path string true "Notifier ID" +// @Success 200 {object} map[string]bool +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /databases/notifier/{id}/is-using [get] +func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + _, err = c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + isUsing, err := c.databaseService.IsNotifierUsing(id) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing}) +} + +// IsStorageUsing +// @Summary Check if storage is being used +// @Description Check if a storage is currently being used by any database +// @Tags databases +// @Produce json +// @Param id path string true "Storage ID" +// @Success 200 {object} map[string]bool +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /databases/storage/{id}/is-using [get] +func (c *DatabaseController) IsStorageUsing(ctx *gin.Context) { + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + _, err = c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + isUsing, err := c.databaseService.IsStorageUsing(id) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing}) +} diff --git a/backend/internal/features/databases/databases/postgresql/model.go b/backend/internal/features/databases/databases/postgresql/model.go new file mode 100644 index 0000000..4aea4cc --- /dev/null +++ b/backend/internal/features/databases/databases/postgresql/model.go @@ -0,0 +1,136 @@ +package postgresql + +import ( + "context" + "errors" + "fmt" + "postgresus-backend/internal/util/logger" + "postgresus-backend/internal/util/tools" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var log = logger.GetLogger() + +type PostgresqlDatabase struct { + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + + DatabaseID *uuid.UUID `json:"databaseId" gorm:"type:uuid;column:database_id"` + RestoreID *uuid.UUID `json:"restoreId" gorm:"type:uuid;column:restore_id"` + + Version tools.PostgresqlVersion `json:"version" gorm:"type:text;not null"` + + // connection data + Host string `json:"host" gorm:"type:text;not null"` + Port int `json:"port" gorm:"type:int;not null"` + Username string `json:"username" gorm:"type:text;not null"` + Password string `json:"password" gorm:"type:text;not null"` + Database *string `json:"database" gorm:"type:text"` + IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"` + + CpuCount int `json:"cpuCount" gorm:"type:int;not null"` +} + +func (p *PostgresqlDatabase) TableName() string { + return "postgresql_databases" +} + +func (p *PostgresqlDatabase) Validate() error { + if p.Version == "" { + return errors.New("version is required") + } + + if p.Host == "" { + return errors.New("host is required") + } + + if p.Port == 0 { + return errors.New("port is required") + } + + if p.Username == "" { + return errors.New("username is required") + } + + if p.Password == "" { + return errors.New("password is required") + } + + return nil +} + +func (p *PostgresqlDatabase) TestConnection() error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + return testSingleDatabaseConnection(ctx, p) +} + +// testSingleDatabaseConnection tests connection to a specific database for pg_dump +func testSingleDatabaseConnection(ctx context.Context, p *PostgresqlDatabase) error { + // For single database backup, we need to connect to the specific database + if p.Database == nil || *p.Database == "" { + return errors.New("database name is required for single database backup (pg_dump)") + } + + // Build connection string for the specific database + connStr := buildConnectionStringForDB(p, *p.Database) + + // Test connection + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + // TODO make more readable errors: + // - handle wrong creds + // - handle wrong database name + // - handle wrong protocol + return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err) + } + defer func() { + if closeErr := conn.Close(ctx); closeErr != nil { + log.Error("Failed to close connection", "error", closeErr) + } + }() + + // Test if we can perform basic operations (like pg_dump would need) + if err := testBasicOperations(ctx, conn, *p.Database); err != nil { + return fmt.Errorf("basic operations test failed for database '%s': %w", *p.Database, err) + } + + return nil +} + +// testBasicOperations tests basic operations that backup tools need +func testBasicOperations(ctx context.Context, conn *pgx.Conn, dbName string) error { + var hasCreatePriv bool + + err := conn.QueryRow(ctx, "SELECT has_database_privilege(current_user, current_database(), 'CONNECT')"). + Scan(&hasCreatePriv) + if err != nil { + return fmt.Errorf("cannot check database privileges: %w", err) + } + + if !hasCreatePriv { + return fmt.Errorf("user does not have CONNECT privilege on database '%s'", dbName) + } + + return nil +} + +// buildConnectionStringForDB builds connection string for specific database +func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string) string { + sslMode := "disable" + if p.IsHttps { + sslMode = "require" + } + + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + p.Host, + p.Port, + p.Username, + p.Password, + dbName, + sslMode, + ) +} diff --git a/backend/internal/features/databases/di.go b/backend/internal/features/databases/di.go new file mode 100644 index 0000000..c3f13f8 --- /dev/null +++ b/backend/internal/features/databases/di.go @@ -0,0 +1,23 @@ +package databases + +import "postgresus-backend/internal/features/users" + +var databaseRepository = &DatabaseRepository{} + +var databaseService = &DatabaseService{ + databaseRepository, + nil, +} + +var databaseController = &DatabaseController{ + databaseService, + users.GetUserService(), +} + +func GetDatabaseService() *DatabaseService { + return databaseService +} + +func GetDatabaseController() *DatabaseController { + return databaseController +} diff --git a/backend/internal/features/databases/dto.go b/backend/internal/features/databases/dto.go new file mode 100644 index 0000000..18cbec7 --- /dev/null +++ b/backend/internal/features/databases/dto.go @@ -0,0 +1 @@ +package databases diff --git a/backend/internal/features/databases/enums.go b/backend/internal/features/databases/enums.go new file mode 100644 index 0000000..60d5c3b --- /dev/null +++ b/backend/internal/features/databases/enums.go @@ -0,0 +1,62 @@ +package databases + +import "time" + +type DatabaseType string + +const ( + DatabaseTypePostgres DatabaseType = "POSTGRES" +) + +type Period string + +const ( + PeriodDay Period = "DAY" + PeriodWeek Period = "WEEK" + PeriodMonth Period = "MONTH" + Period3Month Period = "3_MONTH" + Period6Month Period = "6_MONTH" + PeriodYear Period = "YEAR" + Period2Years Period = "2_YEARS" + Period3Years Period = "3_YEARS" + Period4Years Period = "4_YEARS" + Period5Years Period = "5_YEARS" + PeriodForever Period = "FOREVER" +) + +// ToDuration converts Period to time.Duration +func (p Period) ToDuration() time.Duration { + switch p { + case PeriodDay: + return 24 * time.Hour + case PeriodWeek: + return 7 * 24 * time.Hour + case PeriodMonth: + return 30 * 24 * time.Hour + case Period3Month: + return 90 * 24 * time.Hour + case Period6Month: + return 180 * 24 * time.Hour + case PeriodYear: + return 365 * 24 * time.Hour + case Period2Years: + return 2 * 365 * 24 * time.Hour + case Period3Years: + return 3 * 365 * 24 * time.Hour + case Period4Years: + return 4 * 365 * 24 * time.Hour + case Period5Years: + return 5 * 365 * 24 * time.Hour + case PeriodForever: + return 0 + default: + panic("unknown period: " + string(p)) + } +} + +type BackupNotificationType string + +const ( + NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED" + NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS" +) diff --git a/backend/internal/features/databases/interfaces.go b/backend/internal/features/databases/interfaces.go new file mode 100644 index 0000000..f703a4e --- /dev/null +++ b/backend/internal/features/databases/interfaces.go @@ -0,0 +1,15 @@ +package databases + +import "github.com/google/uuid" + +type DatabaseValidator interface { + Validate() error +} + +type DatabaseConnector interface { + TestConnection() error +} + +type DatabaseStorageChangeListener interface { + OnBeforeDbStorageChange(dbID uuid.UUID, storageID uuid.UUID) error +} diff --git a/backend/internal/features/databases/model.go b/backend/internal/features/databases/model.go new file mode 100644 index 0000000..b28c594 --- /dev/null +++ b/backend/internal/features/databases/model.go @@ -0,0 +1,116 @@ +package databases + +import ( + "errors" + "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/features/intervals" + "postgresus-backend/internal/features/notifiers" + "postgresus-backend/internal/features/storages" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Database struct { + ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` + UserID uuid.UUID `json:"userId" gorm:"column:user_id;type:uuid;not null"` + Name string `json:"name" gorm:"column:name;type:text;not null"` + Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"` + StorePeriod Period `json:"storePeriod" gorm:"column:store_period;type:text;not null"` + + BackupIntervalID uuid.UUID `json:"backupIntervalId" gorm:"column:backup_interval_id;type:uuid;not null"` + BackupInterval *intervals.Interval `json:"backupInterval,omitempty" gorm:"foreignKey:BackupIntervalID"` + + Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:DatabaseID"` + + Storage storages.Storage `json:"storage" gorm:"foreignKey:StorageID"` + StorageID uuid.UUID `json:"storageId" gorm:"column:storage_id;type:uuid;not null"` + + Notifiers []notifiers.Notifier `json:"notifiers" gorm:"many2many:database_notifiers;"` + SendNotificationsOn []BackupNotificationType `json:"sendNotificationsOn" gorm:"-"` + SendNotificationsOnString string `json:"-" gorm:"column:send_notifications_on;type:text;not null"` + + // these fields are not reliable, but + // they are used for pretty UI + LastBackupTime *time.Time `json:"lastBackupTime,omitempty" gorm:"column:last_backup_time;type:timestamp with time zone"` + LastBackupErrorMessage *string `json:"lastBackupErrorMessage,omitempty" gorm:"column:last_backup_error_message;type:text"` +} + +func (d *Database) BeforeSave(tx *gorm.DB) error { + // Convert SendNotificationsOn array to string + if len(d.SendNotificationsOn) > 0 { + notificationTypes := make([]string, len(d.SendNotificationsOn)) + for i, notificationType := range d.SendNotificationsOn { + notificationTypes[i] = string(notificationType) + } + d.SendNotificationsOnString = strings.Join(notificationTypes, ",") + } else { + d.SendNotificationsOnString = "" + } + + return nil +} + +func (d *Database) AfterFind(tx *gorm.DB) error { + // Convert SendNotificationsOnString to array + if d.SendNotificationsOnString != "" { + notificationTypes := strings.Split(d.SendNotificationsOnString, ",") + d.SendNotificationsOn = make([]BackupNotificationType, len(notificationTypes)) + for i, notificationType := range notificationTypes { + d.SendNotificationsOn[i] = BackupNotificationType(notificationType) + } + } else { + d.SendNotificationsOn = []BackupNotificationType{} + } + + return nil +} + +func (d *Database) Validate() error { + if d.Name == "" { + return errors.New("name is required") + } + + // Backup interval is required either as ID or as object + if d.BackupIntervalID == uuid.Nil && d.BackupInterval == nil { + return errors.New("backup interval is required") + } + + if d.StorePeriod == "" { + return errors.New("store period is required") + } + + if d.Postgresql.CpuCount == 0 { + return errors.New("cpu count is required") + } + + switch d.Type { + case DatabaseTypePostgres: + return d.Postgresql.Validate() + default: + return errors.New("invalid database type: " + string(d.Type)) + } +} + +func (d *Database) ValidateUpdate(old, new Database) error { + if old.Type != new.Type { + return errors.New("database type is not allowed to change") + } + + return nil +} + +func (d *Database) TestConnection() error { + return d.getSpecificDatabase().TestConnection() +} + +func (d *Database) getSpecificDatabase() DatabaseConnector { + switch d.Type { + case DatabaseTypePostgres: + return d.Postgresql + } + + panic("invalid database type: " + string(d.Type)) +} diff --git a/backend/internal/features/databases/repository.go b/backend/internal/features/databases/repository.go new file mode 100644 index 0000000..b85230b --- /dev/null +++ b/backend/internal/features/databases/repository.go @@ -0,0 +1,191 @@ +package databases + +import ( + "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/storage" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type DatabaseRepository struct{} + +func (r *DatabaseRepository) Save(database *Database) error { + db := storage.GetDb() + + isNew := database.ID == uuid.Nil + if isNew { + database.ID = uuid.New() + } + + database.StorageID = database.Storage.ID + + return db.Transaction(func(tx *gorm.DB) error { + if database.BackupInterval != nil { + if database.BackupInterval.ID == uuid.Nil { + if err := tx.Create(database.BackupInterval).Error; err != nil { + return err + } + + database.BackupIntervalID = database.BackupInterval.ID + } else { + if err := tx.Save(database.BackupInterval).Error; err != nil { + return err + } + + database.BackupIntervalID = database.BackupInterval.ID + } + } + + switch database.Type { + case DatabaseTypePostgres: + if database.Postgresql != nil { + database.Postgresql.DatabaseID = &database.ID + } + } + + if isNew { + if err := tx.Create(database). + Omit("Postgresql", "Storage", "Notifiers", "BackupInterval"). + Error; err != nil { + return err + } + } else { + if err := tx.Save(database). + Omit("Postgresql", "Storage", "Notifiers", "BackupInterval"). + Error; err != nil { + return err + } + } + + // Save the specific database type + switch database.Type { + case DatabaseTypePostgres: + if database.Postgresql != nil { + database.Postgresql.DatabaseID = &database.ID + if database.Postgresql.ID == uuid.Nil { + database.Postgresql.ID = uuid.New() + if err := tx.Create(database.Postgresql).Error; err != nil { + return err + } + } else { + if err := tx.Save(database.Postgresql).Error; err != nil { + return err + } + } + } + } + + if err := tx.Model(database).Association("Notifiers").Replace(database.Notifiers); err != nil { + return err + } + + return nil + }) +} + +func (r *DatabaseRepository) FindByID(id uuid.UUID) (*Database, error) { + var database Database + + if err := storage. + GetDb(). + Preload("BackupInterval"). + Preload("Postgresql"). + Preload("Storage"). + Preload("Notifiers"). + Where("id = ?", id). + First(&database).Error; err != nil { + return nil, err + } + + return &database, nil +} + +func (r *DatabaseRepository) FindByUserID(userID uuid.UUID) ([]*Database, error) { + var databases []*Database + + if err := storage. + GetDb(). + Preload("BackupInterval"). + Preload("Postgresql"). + Preload("Storage"). + Preload("Notifiers"). + Where("user_id = ?", userID). + Find(&databases).Error; err != nil { + return nil, err + } + + return databases, nil +} + +func (r *DatabaseRepository) Delete(id uuid.UUID) error { + db := storage.GetDb() + + return db.Transaction(func(tx *gorm.DB) error { + var database Database + if err := tx.Where("id = ?", id).First(&database).Error; err != nil { + return err + } + + if err := tx.Model(&database).Association("Notifiers").Clear(); err != nil { + return err + } + + switch database.Type { + case DatabaseTypePostgres: + if err := tx.Where("database_id = ?", id).Delete(&postgresql.PostgresqlDatabase{}).Error; err != nil { + return err + } + } + + if err := tx.Delete(&Database{}, id).Error; err != nil { + return err + } + + return nil + }) +} + +func (r *DatabaseRepository) IsNotifierUsing(notifierID uuid.UUID) (bool, error) { + var count int64 + + if err := storage. + GetDb(). + Table("database_notifiers"). + Where("notifier_id = ?", notifierID). + Count(&count).Error; err != nil { + return false, err + } + + return count > 0, nil +} + +func (r *DatabaseRepository) IsStorageUsing(storageID uuid.UUID) (bool, error) { + var count int64 + + if err := storage. + GetDb(). + Table("databases"). + Where("storage_id = ?", storageID). + Count(&count).Error; err != nil { + return false, err + } + + return count > 0, nil +} + +func (r *DatabaseRepository) GetAllDatabases() ([]*Database, error) { + var databases []*Database + + if err := storage. + GetDb(). + Preload("BackupInterval"). + Preload("Postgresql"). + Preload("Storage"). + Preload("Notifiers"). + Find(&databases).Error; err != nil { + return nil, err + } + + return databases, nil +} diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go new file mode 100644 index 0000000..c615c01 --- /dev/null +++ b/backend/internal/features/databases/service.go @@ -0,0 +1,183 @@ +package databases + +import ( + "errors" + "fmt" + users_models "postgresus-backend/internal/features/users/models" + "time" + + "github.com/google/uuid" +) + +type DatabaseService struct { + dbRepository *DatabaseRepository + dbStorageChangeListener DatabaseStorageChangeListener +} + +func (s *DatabaseService) SetDatabaseStorageChangeListener( + dbStorageChangeListener DatabaseStorageChangeListener, +) { + s.dbStorageChangeListener = dbStorageChangeListener +} + +func (s *DatabaseService) CreateDatabase( + user *users_models.User, + database *Database, +) error { + database.UserID = user.ID + + if err := database.Validate(); err != nil { + return err + } + + return s.dbRepository.Save(database) +} + +func (s *DatabaseService) UpdateDatabase( + user *users_models.User, + database *Database, +) error { + if database.ID == uuid.Nil { + return errors.New("database ID is required for update") + } + + existingDatabase, err := s.dbRepository.FindByID(database.ID) + if err != nil { + return err + } + + if existingDatabase.UserID != user.ID { + return errors.New("you have not access to this database") + } + + // Validate the update + if err := database.ValidateUpdate(*existingDatabase, *database); err != nil { + return err + } + + if err := database.Validate(); err != nil { + return err + } + + if existingDatabase.Storage.ID != database.Storage.ID { + fmt.Println("OnBeforeDbStorageChange") + + err := s.dbStorageChangeListener.OnBeforeDbStorageChange( + existingDatabase.ID, + database.StorageID, + ) + if err != nil { + return err + } + } + + return s.dbRepository.Save(database) +} + +func (s *DatabaseService) DeleteDatabase( + user *users_models.User, + id uuid.UUID, +) error { + existingDatabase, err := s.dbRepository.FindByID(id) + if err != nil { + return err + } + + if existingDatabase.UserID != user.ID { + return errors.New("you have not access to this database") + } + + return s.dbRepository.Delete(id) +} + +func (s *DatabaseService) GetDatabase( + user *users_models.User, + id uuid.UUID, +) (*Database, error) { + database, err := s.dbRepository.FindByID(id) + if err != nil { + return nil, err + } + + if database.UserID != user.ID { + return nil, errors.New("you have not access to this database") + } + + return database, nil +} + +func (s *DatabaseService) GetDatabasesByUser( + user *users_models.User, +) ([]*Database, error) { + return s.dbRepository.FindByUserID(user.ID) +} + +func (s *DatabaseService) IsNotifierUsing(notifierID uuid.UUID) (bool, error) { + return s.dbRepository.IsNotifierUsing(notifierID) +} + +func (s *DatabaseService) IsStorageUsing(storageID uuid.UUID) (bool, error) { + return s.dbRepository.IsStorageUsing(storageID) +} + +func (s *DatabaseService) TestDatabaseConnection( + user *users_models.User, + databaseID uuid.UUID, +) error { + database, err := s.dbRepository.FindByID(databaseID) + if err != nil { + return err + } + + if database.UserID != user.ID { + return errors.New("you have not access to this database") + } + + err = database.TestConnection() + if err != nil { + lastSaveError := err.Error() + database.LastBackupErrorMessage = &lastSaveError + return err + } + + database.LastBackupErrorMessage = nil + + return s.dbRepository.Save(database) +} + +func (s *DatabaseService) TestDatabaseConnectionDirect( + database *Database, +) error { + return database.TestConnection() +} + +func (s *DatabaseService) GetDatabaseByID( + id uuid.UUID, +) (*Database, error) { + return s.dbRepository.FindByID(id) +} + +func (s *DatabaseService) GetAllDatabases() ([]*Database, error) { + return s.dbRepository.GetAllDatabases() +} + +func (s *DatabaseService) SetBackupError(databaseID uuid.UUID, errorMessage string) error { + database, err := s.dbRepository.FindByID(databaseID) + if err != nil { + return err + } + + database.LastBackupErrorMessage = &errorMessage + return s.dbRepository.Save(database) +} + +func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime time.Time) error { + database, err := s.dbRepository.FindByID(databaseID) + if err != nil { + return err + } + + database.LastBackupTime = &backupTime + database.LastBackupErrorMessage = nil // Clear any previous error + return s.dbRepository.Save(database) +} diff --git a/backend/internal/features/intervals/enums.go b/backend/internal/features/intervals/enums.go new file mode 100644 index 0000000..e4efcec --- /dev/null +++ b/backend/internal/features/intervals/enums.go @@ -0,0 +1,10 @@ +package intervals + +type IntervalType string + +const ( + IntervalHourly IntervalType = "HOURLY" + IntervalDaily IntervalType = "DAILY" + IntervalWeekly IntervalType = "WEEKLY" + IntervalMonthly IntervalType = "MONTHLY" +) diff --git a/backend/internal/features/intervals/model.go b/backend/internal/features/intervals/model.go new file mode 100644 index 0000000..8f5485d --- /dev/null +++ b/backend/internal/features/intervals/model.go @@ -0,0 +1,192 @@ +package intervals + +import ( + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Interval struct { + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + Interval IntervalType `json:"interval" gorm:"type:text;not null"` + + TimeOfDay *string `json:"timeOfDay" gorm:"type:text;"` + // only for WEEKLY + Weekday *int `json:"weekday,omitempty" gorm:"type:int"` + // only for MONTHLY + DayOfMonth *int `json:"dayOfMonth,omitempty" gorm:"type:int"` +} + +func (i *Interval) BeforeSave(tx *gorm.DB) error { + return i.Validate() +} + +func (i *Interval) Validate() error { + // for daily, weekly and monthly intervals time of day is required + if (i.Interval == IntervalDaily || i.Interval == IntervalWeekly || i.Interval == IntervalMonthly) && + i.TimeOfDay == nil { + return errors.New("time of day is required for daily, weekly and monthly intervals") + } + + // for weekly interval weekday is required + if i.Interval == IntervalWeekly && i.Weekday == nil { + return errors.New("weekday is required for weekly intervals") + } + + // for monthly interval day of month is required + if i.Interval == IntervalMonthly && i.DayOfMonth == nil { + return errors.New("day of month is required for monthly intervals") + } + + return nil +} + +// ShouldTriggerBackup checks if a backup should be triggered based on the interval and last backup time +func (i *Interval) ShouldTriggerBackup(now time.Time, lastBackupTime *time.Time) bool { + // If no backup has been made yet, trigger immediately + if lastBackupTime == nil { + return true + } + + switch i.Interval { + case IntervalHourly: + return now.Sub(*lastBackupTime) >= time.Hour + case IntervalDaily: + return i.shouldTriggerDaily(now, *lastBackupTime) + case IntervalWeekly: + return i.shouldTriggerWeekly(now, *lastBackupTime) + case IntervalMonthly: + return i.shouldTriggerMonthly(now, *lastBackupTime) + default: + return false + } +} + +// daily trigger: calendar-based if TimeOfDay set, otherwise next calendar day +func (i *Interval) shouldTriggerDaily(now, lastBackup time.Time) bool { + if i.TimeOfDay != nil { + target, err := time.Parse("15:04", *i.TimeOfDay) + if err == nil { + todayTarget := time.Date( + now.Year(), + now.Month(), + now.Day(), + target.Hour(), + target.Minute(), + 0, + 0, + now.Location(), + ) + + // if it's past today's target time and we haven't backed up today + if now.After(todayTarget) && !isSameDay(lastBackup, now) { + return true + } + + // if it's exactly the target time and we haven't backed up today + if now.Equal(todayTarget) && !isSameDay(lastBackup, now) { + return true + } + + // if it's before today's target time, don't trigger yet + if now.Before(todayTarget) { + return false + } + } + } + // no TimeOfDay: if it's a new calendar day + return !isSameDay(lastBackup, now) +} + +// weekly trigger: on specified weekday/calendar week, otherwise ≥7 days +func (i *Interval) shouldTriggerWeekly(now, lastBackup time.Time) bool { + if i.Weekday != nil { + targetWd := time.Weekday(*i.Weekday) + startOfWeek := getStartOfWeek(now) + + // today is target weekday and no backup this week + if now.Weekday() == targetWd && lastBackup.Before(startOfWeek) { + if i.TimeOfDay != nil { + t, err := time.Parse("15:04", *i.TimeOfDay) + if err == nil { + todayT := time.Date( + now.Year(), + now.Month(), + now.Day(), + t.Hour(), + t.Minute(), + 0, + 0, + now.Location(), + ) + return now.After(todayT) || now.Equal(todayT) + } + } + return true + } + // passed this week's slot and missed entirely + targetThisWeek := startOfWeek.AddDate(0, 0, int(targetWd)) + if now.After(targetThisWeek) && lastBackup.Before(startOfWeek) { + return true + } + return false + } + // no Weekday: generic 7-day interval + return now.Sub(lastBackup) >= 7*24*time.Hour +} + +// monthly trigger: on specified day/calendar month, otherwise next calendar month +func (i *Interval) shouldTriggerMonthly(now, lastBackup time.Time) bool { + if i.DayOfMonth != nil { + day := *i.DayOfMonth + startOfMonth := getStartOfMonth(now) + + // today is target day and no backup this month + if now.Day() == day && lastBackup.Before(startOfMonth) { + if i.TimeOfDay != nil { + t, err := time.Parse("15:04", *i.TimeOfDay) + if err == nil { + todayT := time.Date( + now.Year(), + now.Month(), + now.Day(), + t.Hour(), + t.Minute(), + 0, + 0, + now.Location(), + ) + return now.After(todayT) || now.Equal(todayT) + } + } + return true + } + // passed this month's slot and missed entirely + if now.Day() > day && lastBackup.Before(startOfMonth) { + return true + } + return false + } + // no DayOfMonth: if we're in a new calendar month + return lastBackup.Before(getStartOfMonth(now)) +} + +func isSameDay(a, b time.Time) bool { + y1, m1, d1 := a.Date() + y2, m2, d2 := b.Date() + return y1 == y2 && m1 == m2 && d1 == d2 +} + +func getStartOfWeek(t time.Time) time.Time { + wd := int(t.Weekday()) + if wd == 0 { + wd = 7 + } + return time.Date(t.Year(), t.Month(), t.Day()-wd+1, 0, 0, 0, 0, t.Location()) +} + +func getStartOfMonth(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) +} diff --git a/backend/internal/features/intervals/model_test.go b/backend/internal/features/intervals/model_test.go new file mode 100644 index 0000000..1b87690 --- /dev/null +++ b/backend/internal/features/intervals/model_test.go @@ -0,0 +1,337 @@ +package intervals + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestInterval_ShouldTriggerBackup_Hourly(t *testing.T) { + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalHourly, + } + + baseTime := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + + t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) { + should := interval.ShouldTriggerBackup(baseTime, nil) + assert.True(t, should) + }) + + t.Run("Last backup 59 minutes ago: Do not trigger backup", func(t *testing.T) { + lastBackup := baseTime.Add(-59 * time.Minute) + should := interval.ShouldTriggerBackup(baseTime, &lastBackup) + assert.False(t, should) + }) + + t.Run("Last backup exactly 1 hour ago: Trigger backup", func(t *testing.T) { + lastBackup := baseTime.Add(-1 * time.Hour) + should := interval.ShouldTriggerBackup(baseTime, &lastBackup) + assert.True(t, should) + }) + + t.Run("Last backup 2 hours ago: Trigger backup", func(t *testing.T) { + lastBackup := baseTime.Add(-2 * time.Hour) + should := interval.ShouldTriggerBackup(baseTime, &lastBackup) + assert.True(t, should) + }) +} + +func TestInterval_ShouldTriggerBackup_Daily(t *testing.T) { + timeOfDay := "09:00" + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalDaily, + TimeOfDay: &timeOfDay, + } + + // Base time: January 15, 2024 + baseDate := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + + t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) { + now := baseDate.Add(10 * time.Hour) // 10:00 AM + should := interval.ShouldTriggerBackup(now, nil) + assert.True(t, should) + }) + + t.Run("Today 08:59, no backup today: Do not trigger backup", func(t *testing.T) { + now := time.Date(2024, 1, 15, 8, 59, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.False(t, should) + }) + + t.Run("Today exactly 09:00, no backup today: Trigger backup", func(t *testing.T) { + now := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }) + + t.Run("Today 09:01, no backup today: Trigger backup", func(t *testing.T) { + now := time.Date(2024, 1, 15, 9, 1, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }) + + t.Run("Backup earlier today at 09:00: Do not trigger another backup", func(t *testing.T) { + now := time.Date(2024, 1, 15, 15, 0, 0, 0, time.UTC) // 3 PM + lastBackup := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC) // Today at 9 AM + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.False(t, should) + }) + + t.Run( + "Backup yesterday at correct time: Trigger backup today at or after 09:00", + func(t *testing.T) { + now := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 14, 9, 0, 0, 0, time.UTC) // Yesterday at 9 AM + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) +} + +func TestInterval_ShouldTriggerBackup_Weekly(t *testing.T) { + timeOfDay := "15:00" + weekday := 3 // Wednesday (0=Sunday, 1=Monday, ..., 3=Wednesday) + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalWeekly, + TimeOfDay: &timeOfDay, + Weekday: &weekday, + } + + // Base time: Wednesday, January 17, 2024 (to ensure we're on Wednesday) + wednesday := time.Date(2024, 1, 17, 0, 0, 0, 0, time.UTC) + + t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) { + now := wednesday.Add(16 * time.Hour) // 4 PM Wednesday + should := interval.ShouldTriggerBackup(now, nil) + assert.True(t, should) + }) + + t.Run( + "Today Wednesday at 14:59, no backup this week: Do not trigger backup", + func(t *testing.T) { + now := time.Date(2024, 1, 17, 14, 59, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.False(t, should) + }, + ) + + t.Run( + "Today Wednesday at exactly 15:00, no backup this week: Trigger backup", + func(t *testing.T) { + now := time.Date(2024, 1, 17, 15, 0, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) + + t.Run("Today Wednesday at 15:01, no backup this week: Trigger backup", func(t *testing.T) { + now := time.Date(2024, 1, 17, 15, 1, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }) + + t.Run( + "Backup already done this week (Wednesday 15:00): Do not trigger again", + func(t *testing.T) { + now := time.Date(2024, 1, 18, 10, 0, 0, 0, time.UTC) // Thursday + lastBackup := time.Date(2024, 1, 17, 15, 0, 0, 0, time.UTC) // Wednesday this week + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.False(t, should) + }, + ) + + t.Run( + "Backup missed yesterday (it's Thursday): Trigger backup immediately", + func(t *testing.T) { + now := time.Date(2024, 1, 18, 10, 0, 0, 0, time.UTC) // Thursday + lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) + + t.Run( + "Backup last week: Trigger backup at this week's scheduled time or immediately if already missed", + func(t *testing.T) { + now := time.Date( + 2024, + 1, + 17, + 15, + 0, + 0, + 0, + time.UTC, + ) // Wednesday at scheduled time + lastBackup := time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC) // Previous week + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) +} + +func TestInterval_ShouldTriggerBackup_Monthly(t *testing.T) { + timeOfDay := "08:00" + dayOfMonth := 10 + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalMonthly, + TimeOfDay: &timeOfDay, + DayOfMonth: &dayOfMonth, + } + + t.Run("No previous backup: Trigger backup immediately", func(t *testing.T) { + now := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + should := interval.ShouldTriggerBackup(now, nil) + assert.True(t, should) + }) + + t.Run( + "Today is the 10th at 07:59, no backup this month: Do not trigger backup", + func(t *testing.T) { + now := time.Date(2024, 1, 10, 7, 59, 0, 0, time.UTC) + lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.False(t, should) + }, + ) + + t.Run( + "Today is the 10th exactly 08:00, no backup this month: Trigger backup", + func(t *testing.T) { + now := time.Date(2024, 1, 10, 8, 0, 0, 0, time.UTC) + lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) + + t.Run( + "Today is the 10th after 08:00, no backup this month: Trigger backup", + func(t *testing.T) { + now := time.Date(2024, 1, 10, 8, 1, 0, 0, time.UTC) + lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) + + t.Run( + "Today is the 11th, backup missed on the 10th: Trigger backup immediately", + func(t *testing.T) { + now := time.Date(2024, 1, 11, 10, 0, 0, 0, time.UTC) + lastBackup := time.Date(2023, 12, 10, 8, 0, 0, 0, time.UTC) // Previous month + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) + + t.Run("Backup already performed this month: Do not trigger again", func(t *testing.T) { + now := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + lastBackup := time.Date(2024, 1, 10, 8, 0, 0, 0, time.UTC) // This month + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.False(t, should) + }) + + t.Run( + "Backup performed last month on schedule: Trigger backup this month at or after scheduled date/time", + func(t *testing.T) { + now := time.Date(2024, 1, 10, 8, 0, 0, 0, time.UTC) + lastBackup := time.Date( + 2023, + 12, + 10, + 8, + 0, + 0, + 0, + time.UTC, + ) // Previous month at scheduled time + should := interval.ShouldTriggerBackup(now, &lastBackup) + assert.True(t, should) + }, + ) +} + +func TestInterval_Validate(t *testing.T) { + t.Run("Daily interval requires time of day", func(t *testing.T) { + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalDaily, + } + err := interval.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "time of day is required") + }) + + t.Run("Weekly interval requires weekday", func(t *testing.T) { + timeOfDay := "09:00" + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalWeekly, + TimeOfDay: &timeOfDay, + } + err := interval.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "weekday is required") + }) + + t.Run("Monthly interval requires day of month", func(t *testing.T) { + timeOfDay := "09:00" + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalMonthly, + TimeOfDay: &timeOfDay, + } + err := interval.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "day of month is required") + }) + + t.Run("Hourly interval is valid without additional fields", func(t *testing.T) { + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalHourly, + } + err := interval.Validate() + assert.NoError(t, err) + }) + + t.Run("Valid weekly interval", func(t *testing.T) { + timeOfDay := "09:00" + weekday := 1 + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalWeekly, + TimeOfDay: &timeOfDay, + Weekday: &weekday, + } + err := interval.Validate() + assert.NoError(t, err) + }) + + t.Run("Valid monthly interval", func(t *testing.T) { + timeOfDay := "09:00" + dayOfMonth := 15 + interval := &Interval{ + ID: uuid.New(), + Interval: IntervalMonthly, + TimeOfDay: &timeOfDay, + DayOfMonth: &dayOfMonth, + } + err := interval.Validate() + assert.NoError(t, err) + }) +} diff --git a/backend/internal/features/notifiers/controller.go b/backend/internal/features/notifiers/controller.go new file mode 100644 index 0000000..f220900 --- /dev/null +++ b/backend/internal/features/notifiers/controller.go @@ -0,0 +1,225 @@ +package notifiers + +import ( + "net/http" + "postgresus-backend/internal/features/users" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type NotifierController struct { + notifierService *NotifierService + userService *users.UserService +} + +func (c *NotifierController) RegisterRoutes(router *gin.RouterGroup) { + router.POST("/notifiers", c.SaveNotifier) + router.GET("/notifiers", c.GetNotifiers) + router.GET("/notifiers/:id", c.GetNotifier) + router.DELETE("/notifiers/:id", c.DeleteNotifier) + router.POST("/notifiers/:id/test", c.SendTestNotification) + router.POST("/notifiers/direct-test", c.SendTestNotificationDirect) +} + +// SaveNotifier +// @Summary Save a notifier +// @Description Create or update a notifier +// @Tags notifiers +// @Accept json +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param notifier body Notifier true "Notifier data" +// @Success 200 {object} Notifier +// @Failure 400 +// @Failure 401 +// @Router /notifiers [post] +func (c *NotifierController) SaveNotifier(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + var notifier Notifier + if err := ctx.ShouldBindJSON(¬ifier); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := notifier.Validate(); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := c.notifierService.SaveNotifier(user, ¬ifier); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, notifier) +} + +// GetNotifier +// @Summary Get a notifier by ID +// @Description Get a specific notifier by ID +// @Tags notifiers +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param id path string true "Notifier ID" +// @Success 200 {object} Notifier +// @Failure 400 +// @Failure 401 +// @Router /notifiers/{id} [get] +func (c *NotifierController) GetNotifier(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"}) + return + } + + notifier, err := c.notifierService.GetNotifier(user, id) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, notifier) +} + +// GetNotifiers +// @Summary Get all notifiers +// @Description Get all notifiers for the current user +// @Tags notifiers +// @Produce json +// @Param Authorization header string true "JWT token" +// @Success 200 {array} Notifier +// @Failure 401 +// @Router /notifiers [get] +func (c *NotifierController) GetNotifiers(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + notifiers, err := c.notifierService.GetNotifiers(user) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, notifiers) +} + +// DeleteNotifier +// @Summary Delete a notifier +// @Description Delete a notifier by ID +// @Tags notifiers +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param id path string true "Notifier ID" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Router /notifiers/{id} [delete] +func (c *NotifierController) DeleteNotifier(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"}) + return + } + + notifier, err := c.notifierService.GetNotifier(user, id) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := c.notifierService.DeleteNotifier(user, notifier.ID); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "notifier deleted successfully"}) +} + +// SendTestNotification +// @Summary Send test notification +// @Description Send a test notification using the specified notifier +// @Tags notifiers +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param id path string true "Notifier ID" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Router /notifiers/{id}/test [post] +func (c *NotifierController) SendTestNotification(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid notifier ID"}) + return + } + + if err := c.notifierService.SendTestNotification(user, id); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "test notification sent successfully"}) +} + +// SendTestNotificationDirect +// @Summary Send test notification directly +// @Description Send a test notification using a notifier object provided in the request +// @Tags notifiers +// @Accept json +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param notifier body Notifier true "Notifier data" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Router /notifiers/direct-test [post] +func (c *NotifierController) SendTestNotificationDirect(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + var notifier Notifier + if err := ctx.ShouldBindJSON(¬ifier); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // For direct test, associate with the current user + notifier.UserID = user.ID + + if err := c.notifierService.SendTestNotificationToNotifier(¬ifier); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "test notification sent successfully"}) +} diff --git a/backend/internal/features/notifiers/di.go b/backend/internal/features/notifiers/di.go new file mode 100644 index 0000000..5f07ece --- /dev/null +++ b/backend/internal/features/notifiers/di.go @@ -0,0 +1,20 @@ +package notifiers + +import "postgresus-backend/internal/features/users" + +var notifierRepository = &NotifierRepository{} +var notifierService = &NotifierService{ + notifierRepository, +} +var notifierController = &NotifierController{ + notifierService, + users.GetUserService(), +} + +func GetNotifierController() *NotifierController { + return notifierController +} + +func GetNotifierService() *NotifierService { + return notifierService +} diff --git a/backend/internal/features/notifiers/enums.go b/backend/internal/features/notifiers/enums.go new file mode 100644 index 0000000..52707d2 --- /dev/null +++ b/backend/internal/features/notifiers/enums.go @@ -0,0 +1,8 @@ +package notifiers + +type NotifierType string + +const ( + NotifierTypeEmail NotifierType = "EMAIL" + NotifierTypeTelegram NotifierType = "TELEGRAM" +) diff --git a/backend/internal/features/notifiers/interfaces.go b/backend/internal/features/notifiers/interfaces.go new file mode 100644 index 0000000..d04ca2c --- /dev/null +++ b/backend/internal/features/notifiers/interfaces.go @@ -0,0 +1,7 @@ +package notifiers + +type NotificationSender interface { + Send(heading string, message string) error + + Validate() error +} diff --git a/backend/internal/features/notifiers/model.go b/backend/internal/features/notifiers/model.go new file mode 100644 index 0000000..ffff8f2 --- /dev/null +++ b/backend/internal/features/notifiers/model.go @@ -0,0 +1,56 @@ +package notifiers + +import ( + "errors" + "postgresus-backend/internal/features/notifiers/notifiers/email_notifier" + telegram_notifier "postgresus-backend/internal/features/notifiers/notifiers/telegram" + + "github.com/google/uuid" +) + +type Notifier struct { + ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` + UserID uuid.UUID `json:"userId" gorm:"column:user_id;not null;type:uuid;index"` + Name string `json:"name" gorm:"column:name;not null;type:varchar(255)"` + NotifierType NotifierType `json:"notifierType" gorm:"column:notifier_type;not null;type:varchar(50)"` + LastSendError *string `json:"lastSendError" gorm:"column:last_send_error;type:text"` + + // specific notifier + TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"` + EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"` +} + +func (n *Notifier) TableName() string { + return "notifiers" +} + +func (n *Notifier) Validate() error { + if n.Name == "" { + return errors.New("name is required") + } + + return n.getSpecificNotifier().Validate() +} + +func (n *Notifier) Send(heading string, message string) error { + err := n.getSpecificNotifier().Send(heading, message) + if err != nil { + lastSendError := err.Error() + n.LastSendError = &lastSendError + } else { + n.LastSendError = nil + } + + return err +} + +func (n *Notifier) getSpecificNotifier() NotificationSender { + switch n.NotifierType { + case NotifierTypeTelegram: + return n.TelegramNotifier + case NotifierTypeEmail: + return n.EmailNotifier + default: + panic("unknown notifier type: " + string(n.NotifierType)) + } +} diff --git a/backend/internal/features/notifiers/notifiers/email_notifier/model.go b/backend/internal/features/notifiers/notifiers/email_notifier/model.go new file mode 100644 index 0000000..4bad853 --- /dev/null +++ b/backend/internal/features/notifiers/notifiers/email_notifier/model.go @@ -0,0 +1,202 @@ +package email_notifier + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "net/smtp" + "time" + + "github.com/google/uuid" +) + +const ( + ImplicitTLSPort = 465 + DefaultTimeout = 5 * time.Second + DefaultHelloName = "localhost" + MIMETypeHTML = "text/html" + MIMECharsetUTF8 = "UTF-8" +) + +type EmailNotifier struct { + NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;type:uuid;column:notifier_id"` + TargetEmail string `json:"targetEmail" gorm:"not null;type:varchar(255);column:target_email"` + SMTPHost string `json:"smtpHost" gorm:"not null;type:varchar(255);column:smtp_host"` + SMTPPort int `json:"smtpPort" gorm:"not null;column:smtp_port"` + SMTPUser string `json:"smtpUser" gorm:"not null;type:varchar(255);column:smtp_user"` + SMTPPassword string `json:"smtpPassword" gorm:"not null;type:varchar(255);column:smtp_password"` +} + +func (e *EmailNotifier) TableName() string { + return "email_notifiers" +} + +func (e *EmailNotifier) Validate() error { + if e.TargetEmail == "" { + return errors.New("target email is required") + } + + if e.SMTPHost == "" { + return errors.New("SMTP host is required") + } + + if e.SMTPPort == 0 { + return errors.New("SMTP port is required") + } + + if e.SMTPUser == "" { + return errors.New("SMTP user is required") + } + + if e.SMTPPassword == "" { + return errors.New("SMTP password is required") + } + + return nil +} + +func (e *EmailNotifier) Send(heading string, message string) error { + // Compose email + from := e.SMTPUser + to := []string{e.TargetEmail} + + // Format the email content + subject := fmt.Sprintf("Subject: %s\r\n", heading) + mime := fmt.Sprintf( + "MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n", + MIMETypeHTML, + MIMECharsetUTF8, + ) + body := message + fromHeader := fmt.Sprintf("From: %s\r\n", from) + + // Combine all parts of the email + emailContent := []byte(fromHeader + subject + mime + body) + + addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort)) + timeout := DefaultTimeout + + // Handle different port scenarios + if e.SMTPPort == ImplicitTLSPort { + // Implicit TLS (port 465) + // Set up TLS config + tlsConfig := &tls.Config{ + ServerName: e.SMTPHost, + } + + // Dial with timeout + dialer := &net.Dialer{Timeout: timeout} + conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer func() { + _ = conn.Close() + }() + + // Create SMTP client + client, err := smtp.NewClient(conn, e.SMTPHost) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer func() { + _ = client.Quit() + }() + + // Set up authentication + auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + // Set sender and recipients + if err := client.Mail(from); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + for _, recipient := range to { + if err := client.Rcpt(recipient); err != nil { + return fmt.Errorf("failed to set recipient: %w", err) + } + } + + // Send the email body + writer, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + _, err = writer.Write(emailContent) + if err != nil { + return fmt.Errorf("failed to write email content: %w", err) + } + err = writer.Close() + if err != nil { + return fmt.Errorf("failed to close data writer: %w", err) + } + + return nil + } else { + // STARTTLS (port 587) or other ports + // Set up authentication information + auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost) + + // Create a custom dialer with timeout + dialer := &net.Dialer{Timeout: timeout} + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + + // Create client from connection + client, err := smtp.NewClient(conn, e.SMTPHost) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer func() { + _ = client.Quit() + }() + + // Send email using the client + if err := client.Hello(DefaultHelloName); err != nil { + return fmt.Errorf("SMTP hello failed: %w", err) + } + + // Start TLS if available + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: e.SMTPHost}); err != nil { + return fmt.Errorf("STARTTLS failed: %w", err) + } + } + + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + if err := client.Mail(from); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + + for _, recipient := range to { + if err := client.Rcpt(recipient); err != nil { + return fmt.Errorf("failed to set recipient: %w", err) + } + } + + writer, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + + _, err = writer.Write(emailContent) + if err != nil { + return fmt.Errorf("failed to write email content: %w", err) + } + + err = writer.Close() + if err != nil { + return fmt.Errorf("failed to close data writer: %w", err) + } + + return client.Quit() + } +} diff --git a/backend/internal/features/notifiers/notifiers/telegram/model.go b/backend/internal/features/notifiers/notifiers/telegram/model.go new file mode 100644 index 0000000..08ef907 --- /dev/null +++ b/backend/internal/features/notifiers/notifiers/telegram/model.go @@ -0,0 +1,75 @@ +package telegram_notifier + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" +) + +type TelegramNotifier struct { + NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"` + BotToken string `json:"botToken" gorm:"not null;column:bot_token"` + TargetChatID string `json:"targetChatId" gorm:"not null;column:target_chat_id"` +} + +func (t *TelegramNotifier) TableName() string { + return "telegram_notifiers" +} + +func (t *TelegramNotifier) Validate() error { + if t.BotToken == "" { + return errors.New("bot token is required") + } + + if t.TargetChatID == "" { + return errors.New("target chat ID is required") + } + + return nil +} + +func (t *TelegramNotifier) Send(heading string, message string) error { + fullMessage := heading + if message != "" { + fullMessage = fmt.Sprintf("%s\n\n%s", heading, message) + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken) + + data := url.Values{} + data.Set("chat_id", t.TargetChatID) + data.Set("text", fullMessage) + data.Set("parse_mode", "HTML") + + req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send telegram message: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "telegram API returned non-OK status: %s. Error: %s", + resp.Status, + string(bodyBytes), + ) + } + + return nil +} diff --git a/backend/internal/features/notifiers/repository.go b/backend/internal/features/notifiers/repository.go new file mode 100644 index 0000000..5f98f0c --- /dev/null +++ b/backend/internal/features/notifiers/repository.go @@ -0,0 +1,113 @@ +package notifiers + +import ( + "postgresus-backend/internal/storage" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type NotifierRepository struct{} + +func (r *NotifierRepository) Save(notifier *Notifier) error { + db := storage.GetDb() + + return db.Transaction(func(tx *gorm.DB) error { + switch notifier.NotifierType { + case NotifierTypeTelegram: + if notifier.TelegramNotifier != nil { + notifier.TelegramNotifier.NotifierID = notifier.ID + } + case NotifierTypeEmail: + if notifier.EmailNotifier != nil { + notifier.EmailNotifier.NotifierID = notifier.ID + } + } + + if notifier.ID == uuid.Nil { + if err := tx.Create(notifier). + Omit("TelegramNotifier", "EmailNotifier"). + Error; err != nil { + return err + } + } else { + if err := tx.Save(notifier). + Omit("TelegramNotifier", "EmailNotifier"). + Error; err != nil { + return err + } + } + + switch notifier.NotifierType { + case NotifierTypeTelegram: + if notifier.TelegramNotifier != nil { + notifier.TelegramNotifier.NotifierID = notifier.ID // Ensure ID is set + if err := tx.Save(notifier.TelegramNotifier).Error; err != nil { + return err + } + } + case NotifierTypeEmail: + if notifier.EmailNotifier != nil { + notifier.EmailNotifier.NotifierID = notifier.ID // Ensure ID is set + if err := tx.Save(notifier.EmailNotifier).Error; err != nil { + return err + } + } + } + + return nil + }) +} + +func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) { + var notifier Notifier + + if err := storage. + GetDb(). + Preload("TelegramNotifier"). + Preload("EmailNotifier"). + Where("id = ?", id). + First(¬ifier).Error; err != nil { + return nil, err + } + + return ¬ifier, nil +} + +func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error) { + var notifiers []*Notifier + + if err := storage. + GetDb(). + Preload("TelegramNotifier"). + Preload("EmailNotifier"). + Where("user_id = ?", userID). + Find(¬ifiers).Error; err != nil { + return nil, err + } + + return notifiers, nil +} + +func (r *NotifierRepository) Delete(notifier *Notifier) error { + return storage.GetDb().Transaction(func(tx *gorm.DB) error { + // Delete specific notifier based on type + switch notifier.NotifierType { + case NotifierTypeTelegram: + if notifier.TelegramNotifier != nil { + if err := tx.Delete(notifier.TelegramNotifier).Error; err != nil { + return err + } + } + case NotifierTypeEmail: + if notifier.EmailNotifier != nil { + if err := tx.Delete(notifier.EmailNotifier).Error; err != nil { + return err + } + } + } + + // Delete the main notifier + return tx.Delete(notifier).Error + }) +} diff --git a/backend/internal/features/notifiers/service.go b/backend/internal/features/notifiers/service.go new file mode 100644 index 0000000..fd2f0c1 --- /dev/null +++ b/backend/internal/features/notifiers/service.go @@ -0,0 +1,140 @@ +package notifiers + +import ( + "errors" + users_models "postgresus-backend/internal/features/users/models" + "postgresus-backend/internal/util/logger" + + "github.com/google/uuid" +) + +var log = logger.GetLogger() + +type NotifierService struct { + notifierRepository *NotifierRepository +} + +func (s *NotifierService) SaveNotifier( + user *users_models.User, + notifier *Notifier, +) error { + if notifier.ID != uuid.Nil { + existingNotifier, err := s.notifierRepository.FindByID(notifier.ID) + if err != nil { + return err + } + + if existingNotifier.UserID != user.ID { + return errors.New("you have not access to this notifier") + } + + notifier.UserID = existingNotifier.UserID + } else { + notifier.UserID = user.ID + } + + return s.notifierRepository.Save(notifier) +} + +func (s *NotifierService) DeleteNotifier( + user *users_models.User, + notifierID uuid.UUID, +) error { + notifier, err := s.notifierRepository.FindByID(notifierID) + if err != nil { + return err + } + + if notifier.UserID != user.ID { + return errors.New("you have not access to this notifier") + } + + return s.notifierRepository.Delete(notifier) +} + +func (s *NotifierService) GetNotifier( + user *users_models.User, + id uuid.UUID, +) (*Notifier, error) { + notifier, err := s.notifierRepository.FindByID(id) + if err != nil { + return nil, err + } + + if notifier.UserID != user.ID { + return nil, errors.New("you have not access to this notifier") + } + + return notifier, nil +} + +func (s *NotifierService) GetNotifiers( + user *users_models.User, +) ([]*Notifier, error) { + return s.notifierRepository.FindByUserID(user.ID) +} + +func (s *NotifierService) SendTestNotification( + user *users_models.User, + notifierID uuid.UUID, +) error { + notifier, err := s.notifierRepository.FindByID(notifierID) + if err != nil { + return err + } + + if notifier.UserID != user.ID { + return errors.New("you have not access to this notifier") + } + + err = notifier.Send("Test message", "This is a test message") + if err != nil { + return err + } + + if err = s.notifierRepository.Save(notifier); err != nil { + return err + } + + return nil +} + +func (s *NotifierService) SendTestNotificationToNotifier( + notifier *Notifier, +) error { + return notifier.Send("Test message", "This is a test message") +} + +func (s *NotifierService) SendNotification( + notifier *Notifier, + title string, + message string, +) { + // Truncate message to 2000 characters if it's too long + messageRunes := []rune(message) + if len(messageRunes) > 2000 { + message = string(messageRunes[:2000]) + } + + notifiedFromDb, err := s.notifierRepository.FindByID(notifier.ID) + if err != nil { + return + } + + err = notifiedFromDb.Send(title, message) + if err != nil { + errMsg := err.Error() + notifiedFromDb.LastSendError = &errMsg + + err = s.notifierRepository.Save(notifiedFromDb) + if err != nil { + log.Error("Failed to save notifier", "error", err) + } + } + + notifiedFromDb.LastSendError = nil + err = s.notifierRepository.Save(notifiedFromDb) + if err != nil { + log.Error("Failed to save notifier", "error", err) + } +} diff --git a/backend/internal/features/restores/background_service.go b/backend/internal/features/restores/background_service.go new file mode 100644 index 0000000..85a1dfd --- /dev/null +++ b/backend/internal/features/restores/background_service.go @@ -0,0 +1,38 @@ +package restores + +import ( + "postgresus-backend/internal/features/restores/enums" + "postgresus-backend/internal/util/logger" +) + +var log = logger.GetLogger() + +type RestoreBackgroundService struct { + restoreRepository *RestoreRepository +} + +func (s *RestoreBackgroundService) Run() { + if err := s.failRestoresInProgress(); err != nil { + log.Error("Failed to fail restores in progress", "error", err) + panic(err) + } +} + +func (s *RestoreBackgroundService) failRestoresInProgress() error { + restoresInProgress, err := s.restoreRepository.FindByStatus(enums.RestoreStatusInProgress) + if err != nil { + return err + } + + for _, restore := range restoresInProgress { + failMessage := "Restore failed due to application restart" + restore.Status = enums.RestoreStatusFailed + restore.FailMessage = &failMessage + + if err := s.restoreRepository.Save(restore); err != nil { + return err + } + } + + return nil +} diff --git a/backend/internal/features/restores/controller.go b/backend/internal/features/restores/controller.go new file mode 100644 index 0000000..9eca353 --- /dev/null +++ b/backend/internal/features/restores/controller.go @@ -0,0 +1,99 @@ +package restores + +import ( + "net/http" + "postgresus-backend/internal/features/users" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type RestoreController struct { + restoreService *RestoreService + userService *users.UserService +} + +func (c *RestoreController) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/restores/:backupId", c.GetRestores) + router.POST("/restores/:backupId/restore", c.RestoreBackup) +} + +// GetRestores +// @Summary Get restores for a backup +// @Description Get all restores for a specific backup +// @Tags restores +// @Produce json +// @Param backupId path string true "Backup ID" +// @Success 200 {array} models.Restore +// @Failure 400 +// @Failure 401 +// @Router /restores/{backupId} [get] +func (c *RestoreController) GetRestores(ctx *gin.Context) { + backupID, err := uuid.Parse(ctx.Param("backupId")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + restores, err := c.restoreService.GetRestores(user, backupID) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, restores) +} + +// RestoreBackup +// @Summary Restore a backup +// @Description Start a restore process for a specific backup +// @Tags restores +// @Param backupId path string true "Backup ID" +// @Success 200 {object} map[string]string +// @Failure 400 +// @Failure 401 +// @Router /restores/{backupId}/restore [post] +func (c *RestoreController) RestoreBackup(ctx *gin.Context) { + backupID, err := uuid.Parse(ctx.Param("backupId")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"}) + return + } + + var requestDTO RestoreBackupRequest + if err := ctx.ShouldBindJSON(&requestDTO); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + authorizationHeader := ctx.GetHeader("Authorization") + if authorizationHeader == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if err := c.restoreService.RestoreBackupWithAuth(user, backupID, requestDTO); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "restore started successfully"}) +} diff --git a/backend/internal/features/restores/di.go b/backend/internal/features/restores/di.go new file mode 100644 index 0000000..80405dc --- /dev/null +++ b/backend/internal/features/restores/di.go @@ -0,0 +1,31 @@ +package restores + +import ( + "postgresus-backend/internal/features/backups" + "postgresus-backend/internal/features/restores/usecases" + "postgresus-backend/internal/features/users" +) + +var restoreBackupUsecase = &usecases.RestoreBackupUsecase{} +var restoreRepository = &RestoreRepository{} +var restoreService = &RestoreService{ + backups.GetBackupService(), + restoreRepository, + restoreBackupUsecase, +} +var restoreController = &RestoreController{ + restoreService, + users.GetUserService(), +} + +var restoreBackgroundService = &RestoreBackgroundService{ + restoreRepository, +} + +func GetRestoreController() *RestoreController { + return restoreController +} + +func GetRestoreBackgroundService() *RestoreBackgroundService { + return restoreBackgroundService +} diff --git a/backend/internal/features/restores/dto.go b/backend/internal/features/restores/dto.go new file mode 100644 index 0000000..fd62531 --- /dev/null +++ b/backend/internal/features/restores/dto.go @@ -0,0 +1,9 @@ +package restores + +import ( + "postgresus-backend/internal/features/databases/databases/postgresql" +) + +type RestoreBackupRequest struct { + PostgresqlDatabase *postgresql.PostgresqlDatabase `json:"postgresqlDatabase"` +} diff --git a/backend/internal/features/restores/enums/enums.go b/backend/internal/features/restores/enums/enums.go new file mode 100644 index 0000000..cffcc64 --- /dev/null +++ b/backend/internal/features/restores/enums/enums.go @@ -0,0 +1,9 @@ +package enums + +type RestoreStatus string + +const ( + RestoreStatusInProgress RestoreStatus = "IN_PROGRESS" + RestoreStatusCompleted RestoreStatus = "COMPLETED" + RestoreStatusFailed RestoreStatus = "FAILED" +) diff --git a/backend/internal/features/restores/models/model.go b/backend/internal/features/restores/models/model.go new file mode 100644 index 0000000..529236e --- /dev/null +++ b/backend/internal/features/restores/models/model.go @@ -0,0 +1,25 @@ +package models + +import ( + "postgresus-backend/internal/features/backups" + "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/features/restores/enums" + "time" + + "github.com/google/uuid" +) + +type Restore struct { + ID uuid.UUID `json:"id" gorm:"column:id;type:uuid;primaryKey"` + Status enums.RestoreStatus `json:"status" gorm:"column:status;type:text;not null"` + + BackupID uuid.UUID `json:"backupId" gorm:"column:backup_id;type:uuid;not null"` + Backup *backups.Backup + + Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:RestoreID"` + + FailMessage *string `json:"failMessage" gorm:"column:fail_message"` + + RestoreDurationMs int64 `json:"restoreDurationMs" gorm:"column:restore_duration_ms;default:0"` + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;default:now()"` +} diff --git a/backend/internal/features/restores/repository.go b/backend/internal/features/restores/repository.go new file mode 100644 index 0000000..c2fde3a --- /dev/null +++ b/backend/internal/features/restores/repository.go @@ -0,0 +1,80 @@ +package restores + +import ( + "postgresus-backend/internal/features/restores/enums" + "postgresus-backend/internal/features/restores/models" + "postgresus-backend/internal/storage" + + "github.com/google/uuid" +) + +type RestoreRepository struct{} + +func (r *RestoreRepository) Save(restore *models.Restore) error { + db := storage.GetDb() + + isNew := restore.ID == uuid.Nil + if isNew { + restore.ID = uuid.New() + return db.Create(restore). + Omit("Backup"). + Error + } + + return db.Save(restore). + Omit("Backup"). + Error +} + +func (r *RestoreRepository) FindByBackupID(backupID uuid.UUID) ([]*models.Restore, error) { + var restores []*models.Restore + + if err := storage. + GetDb(). + Preload("Backup"). + Preload("Postgresql"). + Where("backup_id = ?", backupID). + Order("created_at DESC"). + Find(&restores).Error; err != nil { + return nil, err + } + + return restores, nil +} + +func (r *RestoreRepository) FindByID(id uuid.UUID) (*models.Restore, error) { + var restore models.Restore + + if err := storage. + GetDb(). + Preload("Backup"). + Preload("Postgresql"). + Where("id = ?", id). + First(&restore).Error; err != nil { + return nil, err + } + + return &restore, nil +} + +func (r *RestoreRepository) FindByStatus(status enums.RestoreStatus) ([]*models.Restore, error) { + var restores []*models.Restore + + if err := storage. + GetDb(). + Preload("Backup.Storage"). + Preload("Backup.Database"). + Preload("Backup"). + Preload("Postgresql"). + Where("status = ?", status). + Order("created_at DESC"). + Find(&restores).Error; err != nil { + return nil, err + } + + return restores, nil +} + +func (r *RestoreRepository) DeleteByID(id uuid.UUID) error { + return storage.GetDb().Delete(&models.Restore{}, "id = ?", id).Error +} diff --git a/backend/internal/features/restores/service.go b/backend/internal/features/restores/service.go new file mode 100644 index 0000000..c7ff3a8 --- /dev/null +++ b/backend/internal/features/restores/service.go @@ -0,0 +1,132 @@ +package restores + +import ( + "errors" + "postgresus-backend/internal/features/backups" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/restores/enums" + "postgresus-backend/internal/features/restores/models" + "postgresus-backend/internal/features/restores/usecases" + users_models "postgresus-backend/internal/features/users/models" + "time" + + "github.com/google/uuid" +) + +type RestoreService struct { + backupService *backups.BackupService + restoreRepository *RestoreRepository + + restoreBackupUsecase *usecases.RestoreBackupUsecase +} + +func (s *RestoreService) GetRestores( + user *users_models.User, + backupID uuid.UUID, +) ([]*models.Restore, error) { + backup, err := s.backupService.GetBackup(backupID) + if err != nil { + return nil, err + } + + if backup.Database.UserID != user.ID { + return nil, errors.New("user does not have access to this backup") + } + + return s.restoreRepository.FindByBackupID(backupID) +} + +func (s *RestoreService) RestoreBackupWithAuth( + user *users_models.User, + backupID uuid.UUID, + requestDTO RestoreBackupRequest, +) error { + backup, err := s.backupService.GetBackup(backupID) + if err != nil { + return err + } + + if backup.Database.UserID != user.ID { + return errors.New("user does not have access to this backup") + } + + go func() { + if err := s.RestoreBackup(backup, requestDTO); err != nil { + log.Error("Failed to restore backup", "error", err) + } + }() + + return nil +} + +func (s *RestoreService) RestoreBackup( + backup *backups.Backup, + requestDTO RestoreBackupRequest, +) error { + if backup.Status != backups.BackupStatusCompleted { + return errors.New("backup is not completed") + } + + if backup.Database.Type == databases.DatabaseTypePostgres { + if requestDTO.PostgresqlDatabase == nil { + return errors.New("postgresql database is required") + } + } + + restore := models.Restore{ + ID: uuid.New(), + Status: enums.RestoreStatusInProgress, + + BackupID: backup.ID, + Backup: backup, + + CreatedAt: time.Now().UTC(), + RestoreDurationMs: 0, + + FailMessage: nil, + } + + // Save the restore first + if err := s.restoreRepository.Save(&restore); err != nil { + return err + } + + // Set the RestoreID on the PostgreSQL database and save it + if requestDTO.PostgresqlDatabase != nil { + requestDTO.PostgresqlDatabase.RestoreID = &restore.ID + restore.Postgresql = requestDTO.PostgresqlDatabase + + // Save the restore again to include the postgresql database + if err := s.restoreRepository.Save(&restore); err != nil { + return err + } + } + + start := time.Now().UTC() + + err := s.restoreBackupUsecase.Execute( + restore, + backup, + ) + if err != nil { + errMsg := err.Error() + restore.FailMessage = &errMsg + restore.Status = enums.RestoreStatusFailed + restore.RestoreDurationMs = time.Since(start).Milliseconds() + + if err := s.restoreRepository.Save(&restore); err != nil { + return err + } + + return err + } + + restore.Status = enums.RestoreStatusCompleted + restore.RestoreDurationMs = time.Since(start).Milliseconds() + + if err := s.restoreRepository.Save(&restore); err != nil { + return err + } + + return nil +} diff --git a/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go b/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go new file mode 100644 index 0000000..480395d --- /dev/null +++ b/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go @@ -0,0 +1,500 @@ +package usecases_postgresql + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "postgresus-backend/internal/config" + "postgresus-backend/internal/features/backups" + "postgresus-backend/internal/features/databases" + pgtypes "postgresus-backend/internal/features/databases/databases/postgresql" + "postgresus-backend/internal/features/restores/models" + "postgresus-backend/internal/util/logger" + "postgresus-backend/internal/util/tools" + + "github.com/google/uuid" +) + +var log = logger.GetLogger() + +type RestorePostgresqlBackupUsecase struct{} + +func (uc *RestorePostgresqlBackupUsecase) Execute( + restore models.Restore, + backup *backups.Backup, +) error { + if restore.Backup.Database.Type != databases.DatabaseTypePostgres { + return errors.New("database type not supported") + } + + log.Info( + "Restoring PostgreSQL backup via pg_restore", + "restoreId", + restore.ID, + "backupId", + backup.ID, + ) + + pg := restore.Postgresql + if pg == nil { + return fmt.Errorf("postgresql configuration is required for restore") + } + + if pg.Database == nil || *pg.Database == "" { + return fmt.Errorf("target database name is required for pg_restore") + } + + // Use parallel jobs based on CPU count (same as backup) + // Cap between 1 and 8 to avoid overwhelming the server + parallelJobs := max(1, min(pg.CpuCount, 8)) + + args := []string{ + "-Fc", // expect custom format (same as backup) + "-j", strconv.Itoa(parallelJobs), // parallel jobs based on CPU count + "--no-password", // Use environment variable for password, prevent prompts + "-h", pg.Host, + "-p", strconv.Itoa(pg.Port), + "-U", pg.Username, + "-d", *pg.Database, + "--verbose", // Add verbose output to help with debugging + "--clean", // Clean (drop) database objects before recreating them + "--if-exists", // Use IF EXISTS when dropping objects + "--no-owner", + } + + return uc.restoreFromStorage( + tools.GetPostgresqlExecutable( + pg.Version, + "pg_restore", + config.GetEnv().EnvMode, + config.GetEnv().PostgresesInstallDir, + ), + args, + pg.Password, + backup, + pg, + ) +} + +// restoreFromStorage restores backup data from storage using pg_restore +func (uc *RestorePostgresqlBackupUsecase) restoreFromStorage( + pgBin string, + args []string, + password string, + backup *backups.Backup, + pgConfig *pgtypes.PostgresqlDatabase, +) error { + log.Info( + "Restoring PostgreSQL backup from storage via temporary file", + "pgBin", + pgBin, + "args", + args, + ) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) + defer cancel() + + // Monitor for shutdown and cancel context if needed + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if config.IsShouldShutdown() { + cancel() + return + } + } + } + }() + + // Create temporary .pgpass file for authentication + pgpassFile, err := uc.createTempPgpassFile(pgConfig, password) + if err != nil { + return fmt.Errorf("failed to create temporary .pgpass file: %w", err) + } + defer func() { + if pgpassFile != "" { + _ = os.Remove(pgpassFile) + } + }() + + // Verify .pgpass file was created successfully + if pgpassFile == "" { + return fmt.Errorf("temporary .pgpass file was not created") + } + + if info, err := os.Stat(pgpassFile); err == nil { + log.Info("Temporary .pgpass file created successfully", + "pgpassFile", pgpassFile, + "size", info.Size(), + "mode", info.Mode(), + ) + } else { + return fmt.Errorf("failed to verify .pgpass file: %w", err) + } + + // Download backup to temporary file + tempBackupFile, cleanupFunc, err := uc.downloadBackupToTempFile(ctx, backup) + if err != nil { + return fmt.Errorf("failed to download backup to temporary file: %w", err) + } + defer cleanupFunc() + + // Add the temporary backup file as the last argument to pg_restore + args = append(args, tempBackupFile) + + return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig) +} + +// downloadBackupToTempFile downloads backup data from storage to a temporary file +func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile( + ctx context.Context, + backup *backups.Backup, +) (string, func(), error) { + // Create temporary directory for backup data + tempDir, err := os.MkdirTemp(config.GetEnv().TempFolder, "restore_"+uuid.New().String()) + if err != nil { + return "", nil, fmt.Errorf("failed to create temporary directory: %w", err) + } + + cleanupFunc := func() { + _ = os.RemoveAll(tempDir) + } + + tempBackupFile := filepath.Join(tempDir, "backup.dump") + + // Get backup data from storage + log.Info( + "Downloading backup file from storage to temporary file", + "backupId", + backup.ID, + "tempFile", + tempBackupFile, + ) + backupReader, err := backup.Storage.GetFile(backup.ID) + if err != nil { + cleanupFunc() + return "", nil, fmt.Errorf("failed to get backup file from storage: %w", err) + } + defer func() { + if err := backupReader.Close(); err != nil { + log.Error("Failed to close backup reader", "error", err) + } + }() + + // Create temporary backup file + tempFile, err := os.Create(tempBackupFile) + if err != nil { + cleanupFunc() + return "", nil, fmt.Errorf("failed to create temporary backup file: %w", err) + } + defer func() { + if err := tempFile.Close(); err != nil { + log.Error("Failed to close temporary file", "error", err) + } + }() + + // Copy backup data to temporary file with shutdown checks + _, err = uc.copyWithShutdownCheck(ctx, tempFile, backupReader) + if err != nil { + cleanupFunc() + return "", nil, fmt.Errorf("failed to write backup to temporary file: %w", err) + } + + // Close the temp file to ensure all data is written + if err := tempFile.Close(); err != nil { + cleanupFunc() + return "", nil, fmt.Errorf("failed to close temporary backup file: %w", err) + } + + log.Info("Backup file written to temporary location", "tempFile", tempBackupFile) + return tempBackupFile, cleanupFunc, nil +} + +// executePgRestore executes the pg_restore command with proper environment setup +func (uc *RestorePostgresqlBackupUsecase) executePgRestore( + ctx context.Context, + pgBin string, + args []string, + pgpassFile string, + pgConfig *pgtypes.PostgresqlDatabase, +) error { + cmd := exec.CommandContext(ctx, pgBin, args...) + log.Info("Executing PostgreSQL restore command", "command", cmd.String()) + + // Setup environment variables + uc.setupPgRestoreEnvironment(cmd, pgpassFile, pgConfig) + + // Verify executable exists and is accessible + if _, err := exec.LookPath(pgBin); err != nil { + return fmt.Errorf( + "PostgreSQL executable not found or not accessible: %s - %w", + pgBin, + err, + ) + } + + // Get stderr to capture any error output + pgStderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + + // Capture stderr in a separate goroutine + stderrCh := make(chan []byte, 1) + go func() { + stderrOutput, _ := io.ReadAll(pgStderr) + stderrCh <- stderrOutput + }() + + // Start pg_restore + if err = cmd.Start(); err != nil { + return fmt.Errorf("start %s: %w", filepath.Base(pgBin), err) + } + + // Wait for the restore to finish + waitErr := cmd.Wait() + stderrOutput := <-stderrCh + + // Check for shutdown before finalizing + if config.IsShouldShutdown() { + return fmt.Errorf("restore cancelled due to shutdown") + } + + if waitErr != nil { + if config.IsShouldShutdown() { + return fmt.Errorf("restore cancelled due to shutdown") + } + + return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args) + } + + return nil +} + +// setupPgRestoreEnvironment configures environment variables for pg_restore +func (uc *RestorePostgresqlBackupUsecase) setupPgRestoreEnvironment( + cmd *exec.Cmd, + pgpassFile string, + pgConfig *pgtypes.PostgresqlDatabase, +) { + // Start with system environment variables + cmd.Env = os.Environ() + + // Use the .pgpass file for authentication + cmd.Env = append(cmd.Env, "PGPASSFILE="+pgpassFile) + log.Info("Using temporary .pgpass file for authentication", "pgpassFile", pgpassFile) + + // Add PostgreSQL-specific environment variables + cmd.Env = append(cmd.Env, "PGCLIENTENCODING=UTF8") + cmd.Env = append(cmd.Env, "PGCONNECT_TIMEOUT=30") + + // Add encoding-related environment variables + cmd.Env = append(cmd.Env, "LC_ALL=C.UTF-8") + cmd.Env = append(cmd.Env, "LANG=C.UTF-8") + cmd.Env = append(cmd.Env, "PGOPTIONS=--client-encoding=UTF8") + + shouldRequireSSL := pgConfig.IsHttps + + // Configure SSL settings + if shouldRequireSSL { + cmd.Env = append(cmd.Env, "PGSSLMODE=require") + log.Info("Using required SSL mode", "configuredHttps", pgConfig.IsHttps) + } else { + cmd.Env = append(cmd.Env, "PGSSLMODE=prefer") + log.Info("Using preferred SSL mode", "configuredHttps", pgConfig.IsHttps) + } + + // Set other SSL parameters to avoid certificate issues + cmd.Env = append(cmd.Env, "PGSSLCERT=") + cmd.Env = append(cmd.Env, "PGSSLKEY=") + cmd.Env = append(cmd.Env, "PGSSLROOTCERT=") + cmd.Env = append(cmd.Env, "PGSSLCRL=") +} + +// handlePgRestoreError processes and formats pg_restore errors +func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError( + waitErr error, + stderrOutput []byte, + pgBin string, + args []string, +) error { + // Enhanced error handling for PostgreSQL connection and restore issues + stderrStr := string(stderrOutput) + errorMsg := fmt.Sprintf( + "%s failed: %v – stderr: %s", + filepath.Base(pgBin), + waitErr, + stderrStr, + ) + + // Check for specific PostgreSQL error patterns + if exitErr, ok := waitErr.(*exec.ExitError); ok { + exitCode := exitErr.ExitCode() + + if exitCode == 1 && strings.TrimSpace(stderrStr) == "" { + errorMsg = fmt.Sprintf( + "%s failed with exit status 1 but provided no error details. "+ + "This often indicates: "+ + "1) Connection timeout or refused connection, "+ + "2) Authentication failure with incorrect credentials, "+ + "3) Database does not exist, "+ + "4) Network connectivity issues, "+ + "5) PostgreSQL server not running, "+ + "6) Backup file is corrupted or incompatible. "+ + "Command executed: %s %s", + filepath.Base(pgBin), + pgBin, + strings.Join(args, " "), + ) + } else if exitCode == -1073741819 { // 0xC0000005 in decimal + errorMsg = fmt.Sprintf( + "%s crashed with access violation (0xC0000005). This may indicate incompatible PostgreSQL version, corrupted installation, or connection issues. stderr: %s", + filepath.Base(pgBin), + stderrStr, + ) + } else if exitCode == 1 || exitCode == 2 { + // Check for common connection and authentication issues + if containsIgnoreCase(stderrStr, "pg_hba.conf") { + errorMsg = fmt.Sprintf( + "PostgreSQL connection rejected by server configuration (pg_hba.conf). stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "no password supplied") || containsIgnoreCase(stderrStr, "fe_sendauth") { + errorMsg = fmt.Sprintf( + "PostgreSQL authentication failed - no password supplied. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "ssl") && containsIgnoreCase(stderrStr, "connection") { + errorMsg = fmt.Sprintf( + "PostgreSQL SSL connection failed. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "connection") && containsIgnoreCase(stderrStr, "refused") { + errorMsg = fmt.Sprintf( + "PostgreSQL connection refused. Check if the server is running and accessible. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "authentication") || containsIgnoreCase(stderrStr, "password") { + errorMsg = fmt.Sprintf( + "PostgreSQL authentication failed. Check username and password. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "timeout") { + errorMsg = fmt.Sprintf( + "PostgreSQL connection timeout. stderr: %s", + stderrStr, + ) + } else if containsIgnoreCase(stderrStr, "database") && containsIgnoreCase(stderrStr, "does not exist") { + errorMsg = fmt.Sprintf( + "Target database does not exist. Create the database before restoring. stderr: %s", + stderrStr, + ) + } + } + } + + return errors.New(errorMsg) +} + +// copyWithShutdownCheck copies data from src to dst while checking for shutdown +func (uc *RestorePostgresqlBackupUsecase) copyWithShutdownCheck( + ctx context.Context, + dst io.Writer, + src io.Reader, +) (int64, error) { + buf := make([]byte, 32*1024) // 32KB buffer + var totalBytesWritten int64 + + for { + select { + case <-ctx.Done(): + return totalBytesWritten, fmt.Errorf("copy cancelled: %w", ctx.Err()) + default: + } + + if config.IsShouldShutdown() { + return totalBytesWritten, fmt.Errorf("copy cancelled due to shutdown") + } + + bytesRead, readErr := src.Read(buf) + if bytesRead > 0 { + bytesWritten, writeErr := dst.Write(buf[0:bytesRead]) + if bytesWritten < 0 || bytesRead < bytesWritten { + bytesWritten = 0 + if writeErr == nil { + writeErr = fmt.Errorf("invalid write result") + } + } + + if writeErr != nil { + return totalBytesWritten, writeErr + } + + if bytesRead != bytesWritten { + return totalBytesWritten, io.ErrShortWrite + } + + totalBytesWritten += int64(bytesWritten) + } + + if readErr != nil { + if readErr != io.EOF { + return totalBytesWritten, readErr + } + + break + } + } + + return totalBytesWritten, nil +} + +// containsIgnoreCase checks if a string contains a substring, ignoring case +func containsIgnoreCase(str, substr string) bool { + return strings.Contains(strings.ToLower(str), strings.ToLower(substr)) +} + +// createTempPgpassFile creates a temporary .pgpass file with the given password +func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile( + pgConfig *pgtypes.PostgresqlDatabase, + password string, +) (string, error) { + if pgConfig == nil || password == "" { + return "", nil + } + + pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s", + pgConfig.Host, + pgConfig.Port, + pgConfig.Username, + password, + ) + + tempDir, err := os.MkdirTemp("", "pgpass") + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + + pgpassFile := filepath.Join(tempDir, ".pgpass") + err = os.WriteFile(pgpassFile, []byte(pgpassContent), 0600) + if err != nil { + return "", fmt.Errorf("failed to write temporary .pgpass file: %w", err) + } + + return pgpassFile, nil +} diff --git a/backend/internal/features/restores/usecases/restore_backup_uc.go b/backend/internal/features/restores/usecases/restore_backup_uc.go new file mode 100644 index 0000000..ce8df8a --- /dev/null +++ b/backend/internal/features/restores/usecases/restore_backup_uc.go @@ -0,0 +1,24 @@ +package usecases + +import ( + "errors" + "postgresus-backend/internal/features/backups" + "postgresus-backend/internal/features/databases" + "postgresus-backend/internal/features/restores/models" + usecases_postgresql "postgresus-backend/internal/features/restores/usecases/postgresql" +) + +type RestoreBackupUsecase struct { + RestorePostgresqlBackupUsecase *usecases_postgresql.RestorePostgresqlBackupUsecase +} + +func (uc *RestoreBackupUsecase) Execute( + restore models.Restore, + backup *backups.Backup, +) error { + if restore.Backup.Database.Type == databases.DatabaseTypePostgres { + return uc.RestorePostgresqlBackupUsecase.Execute(restore, backup) + } + + return errors.New("database type not supported") +} diff --git a/backend/internal/features/storages/controller.go b/backend/internal/features/storages/controller.go new file mode 100644 index 0000000..09c687f --- /dev/null +++ b/backend/internal/features/storages/controller.go @@ -0,0 +1,224 @@ +package storages + +import ( + "net/http" + "postgresus-backend/internal/features/users" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type StorageController struct { + storageService *StorageService + userService *users.UserService +} + +func (c *StorageController) RegisterRoutes(router *gin.RouterGroup) { + router.POST("/storages", c.SaveStorage) + router.GET("/storages", c.GetStorages) + router.GET("/storages/:id", c.GetStorage) + router.DELETE("/storages/:id", c.DeleteStorage) + router.POST("/storages/:id/test", c.TestStorageConnection) + router.POST("/storages/direct-test", c.TestStorageConnectionDirect) +} + +// SaveStorage +// @Summary Save a storage +// @Description Create or update a storage +// @Tags storages +// @Accept json +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param storage body Storage true "Storage data" +// @Success 200 {object} Storage +// @Failure 400 +// @Failure 401 +// @Router /storages [post] +func (c *StorageController) SaveStorage(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + var storage Storage + if err := ctx.ShouldBindJSON(&storage); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := storage.Validate(); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := c.storageService.SaveStorage(user, &storage); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, storage) +} + +// GetStorage +// @Summary Get a storage by ID +// @Description Get a specific storage by ID +// @Tags storages +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param id path string true "Storage ID" +// @Success 200 {object} Storage +// @Failure 400 +// @Failure 401 +// @Router /storages/{id} [get] +func (c *StorageController) GetStorage(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"}) + return + } + + storage, err := c.storageService.GetStorage(user, id) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, storage) +} + +// GetStorages +// @Summary Get all storages +// @Description Get all storages for the current user +// @Tags storages +// @Produce json +// @Param Authorization header string true "JWT token" +// @Success 200 {array} Storage +// @Failure 401 +// @Router /storages [get] +func (c *StorageController) GetStorages(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + storages, err := c.storageService.GetStorages(user) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, storages) +} + +// DeleteStorage +// @Summary Delete a storage +// @Description Delete a storage by ID +// @Tags storages +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param id path string true "Storage ID" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Router /storages/{id} [delete] +func (c *StorageController) DeleteStorage(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"}) + return + } + + if err := c.storageService.DeleteStorage(user, id); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "storage deleted successfully"}) +} + +// TestStorageConnection +// @Summary Test storage connection +// @Description Test the connection to the storage +// @Tags storages +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param id path string true "Storage ID" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Router /storages/{id}/test [post] +func (c *StorageController) TestStorageConnection(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"}) + return + } + + if err := c.storageService.TestStorageConnection(user, id); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "storage connection test successful"}) +} + +// TestStorageConnectionDirect +// @Summary Test storage connection directly +// @Description Test the connection to a storage object provided in the request +// @Tags storages +// @Accept json +// @Produce json +// @Param Authorization header string true "JWT token" +// @Param storage body Storage true "Storage data" +// @Success 200 +// @Failure 400 +// @Failure 401 +// @Router /storages/direct-test [post] +func (c *StorageController) TestStorageConnectionDirect(ctx *gin.Context) { + user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization")) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + var storage Storage + if err := ctx.ShouldBindJSON(&storage); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // For direct test, associate with the current user + storage.UserID = user.ID + + if err := storage.Validate(); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := c.storageService.TestStorageConnectionDirect(&storage); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "storage connection test successful"}) +} diff --git a/backend/internal/features/storages/di.go b/backend/internal/features/storages/di.go new file mode 100644 index 0000000..dae582d --- /dev/null +++ b/backend/internal/features/storages/di.go @@ -0,0 +1,22 @@ +package storages + +import ( + "postgresus-backend/internal/features/users" +) + +var storageRepository = &StorageRepository{} +var storageService = &StorageService{ + storageRepository, +} +var storageController = &StorageController{ + storageService, + users.GetUserService(), +} + +func GetStorageService() *StorageService { + return storageService +} + +func GetStorageController() *StorageController { + return storageController +} diff --git a/backend/internal/features/storages/enums.go b/backend/internal/features/storages/enums.go new file mode 100644 index 0000000..93787c9 --- /dev/null +++ b/backend/internal/features/storages/enums.go @@ -0,0 +1,8 @@ +package storages + +type StorageType string + +const ( + StorageTypeLocal StorageType = "LOCAL" + StorageTypeS3 StorageType = "S3" +) diff --git a/backend/internal/features/storages/interfaces.go b/backend/internal/features/storages/interfaces.go new file mode 100644 index 0000000..63355b1 --- /dev/null +++ b/backend/internal/features/storages/interfaces.go @@ -0,0 +1,19 @@ +package storages + +import ( + "io" + + "github.com/google/uuid" +) + +type StorageFileSaver interface { + SaveFile(fileID uuid.UUID, file io.Reader) error + + GetFile(fileID uuid.UUID) (io.ReadCloser, error) + + DeleteFile(fileID uuid.UUID) error + + Validate() error + + TestConnection() error +} diff --git a/backend/internal/features/storages/model.go b/backend/internal/features/storages/model.go new file mode 100644 index 0000000..b8bc32c --- /dev/null +++ b/backend/internal/features/storages/model.go @@ -0,0 +1,70 @@ +package storages + +import ( + "errors" + "io" + local_storage "postgresus-backend/internal/features/storages/storages/local" + s3_storage "postgresus-backend/internal/features/storages/storages/s3" + + "github.com/google/uuid" +) + +type Storage struct { + ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"` + UserID uuid.UUID `json:"userId" gorm:"column:user_id;not null;type:uuid;index"` + Type StorageType `json:"type" gorm:"column:type;not null;type:text"` + Name string `json:"name" gorm:"column:name;not null;type:text"` + LastSaveError *string `json:"lastSaveError" gorm:"column:last_save_error;type:text"` + + // specific storage + LocalStorage *local_storage.LocalStorage `json:"localStorage" gorm:"foreignKey:StorageID"` + S3Storage *s3_storage.S3Storage `json:"s3Storage" gorm:"foreignKey:StorageID"` +} + +func (s *Storage) SaveFile(fileID uuid.UUID, file io.Reader) error { + err := s.getSpecificStorage().SaveFile(fileID, file) + if err != nil { + lastSaveError := err.Error() + s.LastSaveError = &lastSaveError + return err + } + + s.LastSaveError = nil + + return nil +} + +func (s *Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) { + return s.getSpecificStorage().GetFile(fileID) +} + +func (s *Storage) DeleteFile(fileID uuid.UUID) error { + return s.getSpecificStorage().DeleteFile(fileID) +} + +func (s *Storage) Validate() error { + if s.Type == "" { + return errors.New("storage type is required") + } + + if s.Name == "" { + return errors.New("storage name is required") + } + + return s.getSpecificStorage().Validate() +} + +func (s *Storage) TestConnection() error { + return s.getSpecificStorage().TestConnection() +} + +func (s *Storage) getSpecificStorage() StorageFileSaver { + switch s.Type { + case StorageTypeLocal: + return s.LocalStorage + case StorageTypeS3: + return s.S3Storage + default: + panic("invalid storage type: " + string(s.Type)) + } +} diff --git a/backend/internal/features/storages/repository.go b/backend/internal/features/storages/repository.go new file mode 100644 index 0000000..7fcf7d8 --- /dev/null +++ b/backend/internal/features/storages/repository.go @@ -0,0 +1,113 @@ +package storages + +import ( + db "postgresus-backend/internal/storage" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type StorageRepository struct{} + +func (r *StorageRepository) Save(s *Storage) error { + database := db.GetDb() + + return database.Transaction(func(tx *gorm.DB) error { + switch s.Type { + case StorageTypeLocal: + if s.LocalStorage != nil { + s.LocalStorage.StorageID = s.ID + } + case StorageTypeS3: + if s.S3Storage != nil { + s.S3Storage.StorageID = s.ID + } + } + + if s.ID == uuid.Nil { + if err := tx.Create(s). + Omit("LocalStorage", "S3Storage"). + Error; err != nil { + return err + } + } else { + if err := tx.Save(s). + Omit("LocalStorage", "S3Storage"). + Error; err != nil { + return err + } + } + + switch s.Type { + case StorageTypeLocal: + if s.LocalStorage != nil { + s.LocalStorage.StorageID = s.ID // Ensure ID is set + if err := tx.Save(s.LocalStorage).Error; err != nil { + return err + } + } + case StorageTypeS3: + if s.S3Storage != nil { + s.S3Storage.StorageID = s.ID // Ensure ID is set + if err := tx.Save(s.S3Storage).Error; err != nil { + return err + } + } + } + + return nil + }) +} + +func (r *StorageRepository) FindByID(id uuid.UUID) (*Storage, error) { + var s Storage + + if err := db. + GetDb(). + Preload("LocalStorage"). + Preload("S3Storage"). + Where("id = ?", id). + First(&s).Error; err != nil { + return nil, err + } + + return &s, nil +} + +func (r *StorageRepository) FindByUserID(userID uuid.UUID) ([]*Storage, error) { + var storages []*Storage + + if err := db. + GetDb(). + Preload("LocalStorage"). + Preload("S3Storage"). + Where("user_id = ?", userID). + Find(&storages).Error; err != nil { + return nil, err + } + + return storages, nil +} + +func (r *StorageRepository) Delete(s *Storage) error { + return db.GetDb().Transaction(func(tx *gorm.DB) error { + // Delete specific storage based on type + switch s.Type { + case StorageTypeLocal: + if s.LocalStorage != nil { + if err := tx.Delete(s.LocalStorage).Error; err != nil { + return err + } + } + case StorageTypeS3: + if s.S3Storage != nil { + if err := tx.Delete(s.S3Storage).Error; err != nil { + return err + } + } + } + + // Delete the main storage + return tx.Delete(s).Error + }) +} diff --git a/backend/internal/features/storages/service.go b/backend/internal/features/storages/service.go new file mode 100644 index 0000000..fbd0e9a --- /dev/null +++ b/backend/internal/features/storages/service.go @@ -0,0 +1,108 @@ +package storages + +import ( + "errors" + users_models "postgresus-backend/internal/features/users/models" + + "github.com/google/uuid" +) + +type StorageService struct { + storageRepository *StorageRepository +} + +func (s *StorageService) SaveStorage( + user *users_models.User, + storage *Storage, +) error { + if storage.ID != uuid.Nil { + existingStorage, err := s.storageRepository.FindByID(storage.ID) + if err != nil { + return err + } + + if existingStorage.UserID != user.ID { + return errors.New("you have not access to this storage") + } + + storage.UserID = existingStorage.UserID + } else { + storage.UserID = user.ID + } + + return s.storageRepository.Save(storage) +} + +func (s *StorageService) DeleteStorage( + user *users_models.User, + storageID uuid.UUID, +) error { + storage, err := s.storageRepository.FindByID(storageID) + if err != nil { + return err + } + + if storage.UserID != user.ID { + return errors.New("you have not access to this storage") + } + + return s.storageRepository.Delete(storage) +} + +func (s *StorageService) GetStorage( + user *users_models.User, + id uuid.UUID, +) (*Storage, error) { + storage, err := s.storageRepository.FindByID(id) + if err != nil { + return nil, err + } + + if storage.UserID != user.ID { + return nil, errors.New("you have not access to this storage") + } + + return storage, nil +} + +func (s *StorageService) GetStorages( + user *users_models.User, +) ([]*Storage, error) { + return s.storageRepository.FindByUserID(user.ID) +} + +func (s *StorageService) TestStorageConnection( + user *users_models.User, + storageID uuid.UUID, +) error { + storage, err := s.storageRepository.FindByID(storageID) + if err != nil { + return err + } + + if storage.UserID != user.ID { + return errors.New("you have not access to this storage") + } + + err = storage.TestConnection() + if err != nil { + lastSaveError := err.Error() + storage.LastSaveError = &lastSaveError + return err + } + + storage.LastSaveError = nil + return s.storageRepository.Save(storage) +} + +func (s *StorageService) TestStorageConnectionDirect( + storage *Storage, +) error { + return storage.TestConnection() +} + +func (s *StorageService) GetStorageByID( + id uuid.UUID, +) (*Storage, error) { + return s.storageRepository.FindByID(id) +} diff --git a/backend/internal/features/storages/storages/local/model.go b/backend/internal/features/storages/storages/local/model.go new file mode 100644 index 0000000..fa4f759 --- /dev/null +++ b/backend/internal/features/storages/storages/local/model.go @@ -0,0 +1,176 @@ +package local_storage + +import ( + "fmt" + "io" + "os" + "path/filepath" + "postgresus-backend/internal/config" + "postgresus-backend/internal/util/logger" + + "github.com/google/uuid" +) + +var log = logger.GetLogger() + +// LocalStorage uses ./postgresus_local_backups folder as a +// directory for backups and ./postgresus_local_temp folder as a +// directory for temp files +type LocalStorage struct { + StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"` +} + +func (l *LocalStorage) TableName() string { + return "local_storages" +} + +func (l *LocalStorage) SaveFile(fileID uuid.UUID, file io.Reader) error { + log.Info("Starting to save file to local storage", "fileId", fileID.String()) + + if err := l.ensureDirectories(); err != nil { + log.Error("Failed to ensure directories", "fileId", fileID.String(), "error", err) + return err + } + + tempFilePath := filepath.Join(config.GetEnv().TempFolder, fileID.String()) + log.Debug("Creating temp file", "fileId", fileID.String(), "tempPath", tempFilePath) + + tempFile, err := os.Create(tempFilePath) + if err != nil { + log.Error( + "Failed to create temp file", + "fileId", + fileID.String(), + "tempPath", + tempFilePath, + "error", + err, + ) + return fmt.Errorf("failed to create temp file: %w", err) + } + defer func() { + _ = tempFile.Close() + }() + + log.Debug("Copying file data to temp file", "fileId", fileID.String()) + _, err = io.Copy(tempFile, file) + if err != nil { + log.Error("Failed to write to temp file", "fileId", fileID.String(), "error", err) + return fmt.Errorf("failed to write to temp file: %w", err) + } + + if err = tempFile.Sync(); err != nil { + log.Error("Failed to sync temp file", "fileId", fileID.String(), "error", err) + return fmt.Errorf("failed to sync temp file: %w", err) + } + + err = tempFile.Close() + if err != nil { + log.Error("Failed to close temp file", "fileId", fileID.String(), "error", err) + return fmt.Errorf("failed to close temp file: %w", err) + } + + finalPath := filepath.Join(config.GetEnv().DataFolder, fileID.String()) + log.Debug( + "Moving file from temp to final location", + "fileId", + fileID.String(), + "finalPath", + finalPath, + ) + + // Move the file from temp to backups directory + if err = os.Rename(tempFilePath, finalPath); err != nil { + log.Error( + "Failed to move file from temp to backups", + "fileId", + fileID.String(), + "tempPath", + tempFilePath, + "finalPath", + finalPath, + "error", + err, + ) + return fmt.Errorf("failed to move file from temp to backups: %w", err) + } + + log.Info( + "Successfully saved file to local storage", + "fileId", + fileID.String(), + "finalPath", + finalPath, + ) + return nil +} + +func (l *LocalStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) { + filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String()) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %s", fileID.String()) + } + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + + return file, nil +} + +func (l *LocalStorage) DeleteFile(fileID uuid.UUID) error { + filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String()) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil + } + + if err := os.Remove(filePath); err != nil { + return fmt.Errorf("failed to delete file: %w", err) + } + + return nil +} + +func (l *LocalStorage) Validate() error { + return l.ensureDirectories() +} + +func (l *LocalStorage) TestConnection() error { + if err := l.ensureDirectories(); err != nil { + return err + } + + testFile := filepath.Join(config.GetEnv().TempFolder, "test_connection") + f, err := os.Create(testFile) + if err != nil { + return fmt.Errorf("failed to create test file: %w", err) + } + if err = f.Close(); err != nil { + return fmt.Errorf("failed to close test file: %w", err) + } + + if err = os.Remove(testFile); err != nil { + return fmt.Errorf("failed to remove test file: %w", err) + } + + return nil +} + +func (l *LocalStorage) ensureDirectories() error { + // Standard permissions for directories: owner + // can read/write/execute, others can read/execute + const directoryPermissions = 0755 + + if err := os.MkdirAll(config.GetEnv().DataFolder, directoryPermissions); err != nil { + return fmt.Errorf("failed to create backups directory: %w", err) + } + + if err := os.MkdirAll(config.GetEnv().TempFolder, directoryPermissions); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + + return nil +} diff --git a/backend/internal/features/storages/storages/s3/model.go b/backend/internal/features/storages/storages/s3/model.go new file mode 100644 index 0000000..6b1f9c9 --- /dev/null +++ b/backend/internal/features/storages/storages/s3/model.go @@ -0,0 +1,175 @@ +package s3_storage + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type S3Storage struct { + StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"` + S3Bucket string `json:"s3Bucket" gorm:"not null;type:text;column:s3_bucket"` + S3Region string `json:"s3Region" gorm:"not null;type:text;column:s3_region"` + S3AccessKey string `json:"s3AccessKey" gorm:"not null;type:text;column:s3_access_key"` + S3SecretKey string `json:"s3SecretKey" gorm:"not null;type:text;column:s3_secret_key"` + S3Endpoint string `json:"s3Endpoint" gorm:"type:text;column:s3_endpoint"` +} + +func (s *S3Storage) TableName() string { + return "s3_storages" +} + +func (s *S3Storage) SaveFile(fileID uuid.UUID, file io.Reader) error { + client, err := s.getClient() + if err != nil { + return err + } + + // Read the entire file into memory + data, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + // Upload the file using MinIO client + _, err = client.PutObject( + context.TODO(), + s.S3Bucket, + fileID.String()+".zip", + bytes.NewReader(data), + int64(len(data)), + minio.PutObjectOptions{}, + ) + if err != nil { + return fmt.Errorf("failed to upload file to S3: %w", err) + } + + return nil +} + +func (s *S3Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) { + client, err := s.getClient() + if err != nil { + return nil, err + } + + // Get the object using MinIO client + object, err := client.GetObject( + context.TODO(), + s.S3Bucket, + fileID.String()+".zip", + minio.GetObjectOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("failed to get file from S3: %w", err) + } + + return object, nil +} + +func (s *S3Storage) DeleteFile(fileID uuid.UUID) error { + client, err := s.getClient() + if err != nil { + return err + } + + // Delete the object using MinIO client + err = client.RemoveObject( + context.TODO(), + s.S3Bucket, + fileID.String()+".zip", + minio.RemoveObjectOptions{}, + ) + if err != nil { + return fmt.Errorf("failed to delete file from S3: %w", err) + } + + return nil +} + +func (s *S3Storage) Validate() error { + if s.S3Bucket == "" { + return errors.New("S3 bucket is required") + } + if s.S3Region == "" { + return errors.New("S3 region is required") + } + if s.S3AccessKey == "" { + return errors.New("S3 access key is required") + } + if s.S3SecretKey == "" { + return errors.New("S3 secret key is required") + } + + // Try to create a client to validate the configuration + _, err := s.getClient() + if err != nil { + return fmt.Errorf("invalid S3 configuration: %w", err) + } + + return nil +} + +func (s *S3Storage) TestConnection() error { + client, err := s.getClient() + if err != nil { + return err + } + + // Create a context with 5 second timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Check if the bucket exists to verify connection + exists, err := client.BucketExists(ctx, s.S3Bucket) + if err != nil { + // Check if the error is due to context deadline exceeded + if errors.Is(err, context.DeadlineExceeded) { + return errors.New("failed to connect to the bucket. Please check params") + } + return fmt.Errorf("failed to connect to S3: %w", err) + } + + if !exists { + return fmt.Errorf("bucket '%s' does not exist", s.S3Bucket) + } + + return nil +} + +func (s *S3Storage) getClient() (*minio.Client, error) { + endpoint := s.S3Endpoint + useSSL := true + + if strings.HasPrefix(endpoint, "http://") { + useSSL = false + endpoint = strings.TrimPrefix(endpoint, "http://") + } else if strings.HasPrefix(endpoint, "https://") { + endpoint = strings.TrimPrefix(endpoint, "https://") + } + + // If no endpoint is provided, use the AWS S3 endpoint for the region + if endpoint == "" { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", s.S3Region) + } + + // Initialize the MinIO client + minioClient, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(s.S3AccessKey, s.S3SecretKey, ""), + Secure: useSSL, + Region: s.S3Region, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize MinIO client: %w", err) + } + + return minioClient, nil +} diff --git a/backend/internal/features/users/controller.go b/backend/internal/features/users/controller.go new file mode 100644 index 0000000..c7e0e49 --- /dev/null +++ b/backend/internal/features/users/controller.go @@ -0,0 +1,86 @@ +package users + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type UserController struct { + userService *UserService +} + +func (c *UserController) RegisterRoutes(router *gin.RouterGroup) { + router.POST("/users/signup", c.SignUp) + router.POST("/users/signin", c.SignIn) + router.GET("/users/is-any-user-exist", c.IsAnyUserExist) +} + +// SignUp +// @Summary Register a new user +// @Description Register a new user with email and password +// @Tags users +// @Accept json +// @Produce json +// @Param request body SignUpRequest true "User signup data" +// @Success 200 +// @Failure 400 +// @Router /users/signup [post] +func (c *UserController) SignUp(ctx *gin.Context) { + var request SignUpRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + err := c.userService.SignUp(&request) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "User created successfully"}) +} + +// SignIn +// @Summary Authenticate a user +// @Description Authenticate a user with email and password +// @Tags users +// @Accept json +// @Produce json +// @Param request body SignInRequest true "User signin data" +// @Success 200 {object} SignInResponse +// @Failure 400 +// @Router /users/signin [post] +func (c *UserController) SignIn(ctx *gin.Context) { + var request SignInRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + response, err := c.userService.SignIn(&request) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, response) +} + +// IsAnyUserExist +// @Summary Check if any user exists +// @Description Check if any user exists in the system +// @Tags users +// @Produce json +// @Success 200 {object} map[string]bool +// @Router /users/is-any-user-exist [get] +func (c *UserController) IsAnyUserExist(ctx *gin.Context) { + isExist, err := c.userService.IsAnyUserExist() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"isExist": isExist}) +} diff --git a/backend/internal/features/users/di.go b/backend/internal/features/users/di.go new file mode 100644 index 0000000..113354f --- /dev/null +++ b/backend/internal/features/users/di.go @@ -0,0 +1,23 @@ +package users + +import ( + user_repositories "postgresus-backend/internal/features/users/repositories" +) + +var secretKeyRepository = &user_repositories.SecretKeyRepository{} +var userRepository = &user_repositories.UserRepository{} +var userService = &UserService{ + userRepository, + secretKeyRepository, +} +var userController = &UserController{ + userService, +} + +func GetUserService() *UserService { + return userService +} + +func GetUserController() *UserController { + return userController +} diff --git a/backend/internal/features/users/dto.go b/backend/internal/features/users/dto.go new file mode 100644 index 0000000..c192ac0 --- /dev/null +++ b/backend/internal/features/users/dto.go @@ -0,0 +1,18 @@ +package users + +import "github.com/google/uuid" + +type SignUpRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8"` +} + +type SignInRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +type SignInResponse struct { + UserID uuid.UUID `json:"userId"` + Token string `json:"token"` +} diff --git a/backend/internal/features/users/enums/user_role.go b/backend/internal/features/users/enums/user_role.go new file mode 100644 index 0000000..29e8c1f --- /dev/null +++ b/backend/internal/features/users/enums/user_role.go @@ -0,0 +1,7 @@ +package user_enums + +type UserRole string + +const ( + UserRoleAdmin UserRole = "ADMIN" +) diff --git a/backend/internal/features/users/models/secret_key.go b/backend/internal/features/users/models/secret_key.go new file mode 100644 index 0000000..dee7264 --- /dev/null +++ b/backend/internal/features/users/models/secret_key.go @@ -0,0 +1,9 @@ +package users_models + +type SecretKey struct { + Secret string `gorm:"column:secret;uniqueIndex;not null"` +} + +func (SecretKey) TableName() string { + return "secret_keys" +} diff --git a/backend/internal/features/users/models/user.go b/backend/internal/features/users/models/user.go new file mode 100644 index 0000000..10ab84b --- /dev/null +++ b/backend/internal/features/users/models/user.go @@ -0,0 +1,21 @@ +package users_models + +import ( + user_enums "postgresus-backend/internal/features/users/enums" + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + Email string `json:"email" gorm:"uniqueIndex;not null"` + HashedPassword string `json:"-" gorm:"not null"` + PasswordCreationTime time.Time `json:"-" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"not null;default:now()"` + Role user_enums.UserRole `json:"role" gorm:"type:text;not null"` +} + +func (User) TableName() string { + return "users" +} diff --git a/backend/internal/features/users/repositories/secret_key_repository.go b/backend/internal/features/users/repositories/secret_key_repository.go new file mode 100644 index 0000000..492a754 --- /dev/null +++ b/backend/internal/features/users/repositories/secret_key_repository.go @@ -0,0 +1,36 @@ +package user_repositories + +import ( + "errors" + user_models "postgresus-backend/internal/features/users/models" + "postgresus-backend/internal/storage" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type SecretKeyRepository struct{} + +func (r *SecretKeyRepository) GetSecretKey() (string, error) { + var secretKey user_models.SecretKey + + if err := storage. + GetDb(). + First(&secretKey).Error; err != nil { + // create a new secret key if not found + if errors.Is(err, gorm.ErrRecordNotFound) { + newSecretKey := user_models.SecretKey{ + Secret: uuid.New().String() + uuid.New().String(), + } + if err := storage.GetDb().Create(&newSecretKey).Error; err != nil { + return "", errors.New("failed to create new secret key") + } + + return newSecretKey.Secret, nil + } + + return "", err + } + + return secretKey.Secret, nil +} diff --git a/backend/internal/features/users/repositories/user_repository.go b/backend/internal/features/users/repositories/user_repository.go new file mode 100644 index 0000000..ff95459 --- /dev/null +++ b/backend/internal/features/users/repositories/user_repository.go @@ -0,0 +1,68 @@ +package user_repositories + +import ( + user_models "postgresus-backend/internal/features/users/models" + "postgresus-backend/internal/storage" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserRepository struct{} + +func (r *UserRepository) IsAnyUserExist() (bool, error) { + var user user_models.User + + if err := storage.GetDb().First(&user).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return false, nil + } + + return false, err + } + + return true, nil +} + +func (r *UserRepository) CreateUser(user *user_models.User) error { + return storage.GetDb().Create(user).Error +} + +func (r *UserRepository) GetUserByEmail(email string) (*user_models.User, error) { + var user user_models.User + if err := storage.GetDb().Where("email = ?", email).First(&user).Error; err != nil { + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) GetUserByID(userID string) (*user_models.User, error) { + var user user_models.User + + if err := storage.GetDb().Where("id = ?", userID).First(&user).Error; err != nil { + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) GetFirstUser() (*user_models.User, error) { + var user user_models.User + + if err := storage.GetDb().First(&user).Error; err != nil { + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) UpdateUserPassword(userID uuid.UUID, hashedPassword string) error { + return storage.GetDb().Model(&user_models.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "hashed_password": hashedPassword, + "password_creation_time": time.Now().UTC(), + }).Error +} diff --git a/backend/internal/features/users/service.go b/backend/internal/features/users/service.go new file mode 100644 index 0000000..7263eaf --- /dev/null +++ b/backend/internal/features/users/service.go @@ -0,0 +1,153 @@ +package users + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + user_enums "postgresus-backend/internal/features/users/enums" + user_models "postgresus-backend/internal/features/users/models" + user_repositories "postgresus-backend/internal/features/users/repositories" +) + +type UserService struct { + userRepository *user_repositories.UserRepository + secretKeyRepository *user_repositories.SecretKeyRepository +} + +func (s *UserService) IsAnyUserExist() (bool, error) { + return s.userRepository.IsAnyUserExist() +} + +func (s *UserService) SignUp(request *SignUpRequest) error { + isAnyUserExists, err := s.userRepository.IsAnyUserExist() + if err != nil { + return fmt.Errorf("failed to check if any user exists: %w", err) + } + + if isAnyUserExists { + return errors.New("admin user already registered") + } + + existingUser, err := s.userRepository.GetUserByEmail(request.Email) + if err == nil && existingUser != nil { + return errors.New("user with this email already exists") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + user := &user_models.User{ + ID: uuid.New(), + Email: request.Email, + HashedPassword: string(hashedPassword), + PasswordCreationTime: time.Now().UTC(), + CreatedAt: time.Now().UTC(), + Role: user_enums.UserRoleAdmin, + } + + if err := s.userRepository.CreateUser(user); err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + return nil +} + +func (s *UserService) SignIn(request *SignInRequest) (*SignInResponse, error) { + user, err := s.userRepository.GetUserByEmail(request.Email) + if err != nil { + return nil, errors.New("user with this email does not exist") + } + + err = bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(request.Password)) + if err != nil { + return nil, errors.New("password is incorrect") + } + + secretKey, err := s.secretKeyRepository.GetSecretKey() + if err != nil { + return nil, fmt.Errorf("failed to get secret key: %w", err) + } + + tenYearsExpiration := time.Now().UTC().Add(time.Hour * 24 * 365 * 10) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": user.ID, + "exp": tenYearsExpiration.Unix(), + "iat": time.Now().UTC().Unix(), + "role": string(user.Role), + }) + + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + return &SignInResponse{ + UserID: user.ID, + Token: tokenString, + }, nil +} + +func (s *UserService) GetUserFromToken(token string) (*user_models.User, error) { + secretKey, err := s.secretKeyRepository.GetSecretKey() + if err != nil { + return nil, fmt.Errorf("failed to get secret key: %w", err) + } + + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secretKey), nil + }) + + if err != nil { + return nil, fmt.Errorf("invalid token: %w", err) + } + + if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid { + userID, ok := claims["sub"].(string) + if !ok { + return nil, errors.New("invalid token claims") + } + + user, err := s.userRepository.GetUserByID(userID) + if err != nil { + return nil, err + } + + return user, nil + } + + return nil, errors.New("invalid token") +} + +func (s *UserService) ChangePassword(newPassword string) error { + exists, err := s.userRepository.IsAnyUserExist() + if err != nil || !exists { + return errors.New("no users exist to change password") + } + + user, err := s.userRepository.GetFirstUser() + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + if err := s.userRepository.UpdateUserPassword(user.ID, string(hashedPassword)); err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go new file mode 100644 index 0000000..85b5c98 --- /dev/null +++ b/backend/internal/storage/storage.go @@ -0,0 +1,55 @@ +package storage + +import ( + "os" + "postgresus-backend/internal/config" + "postgresus-backend/internal/util/logger" + "sync" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" +) + +var log = logger.GetLogger() + +var ( + db *gorm.DB + dbOnce sync.Once +) + +func GetDb() *gorm.DB { + dbOnce.Do(loadDbs) + return db +} + +func loadDbs() { + LoadMainDb() +} + +func LoadMainDb() { + dbDsn := config.GetEnv().DatabaseDsn + + log.Info("Connection to database...") + + database, err := gorm.Open(postgres.Open(dbDsn), &gorm.Config{ + Logger: gormLogger.Default.LogMode(gormLogger.Silent), + }) + if err != nil { + log.Error("error on connecting to database", "error", err) + os.Exit(1) + } + + sqlDB, err := database.DB() + if err != nil { + log.Error("error getting underlying sql.DB", "error", err) + os.Exit(1) + } + + sqlDB.SetMaxOpenConns(20) + sqlDB.SetMaxIdleConns(20) + + db = database + + log.Info("Main database connected successfully!") +} diff --git a/backend/internal/util/env/enums.go b/backend/internal/util/env/enums.go new file mode 100644 index 0000000..fbebc09 --- /dev/null +++ b/backend/internal/util/env/enums.go @@ -0,0 +1,8 @@ +package env_utils + +type EnvMode string + +const ( + EnvModeDevelopment EnvMode = "development" + EnvModeProduction EnvMode = "production" +) diff --git a/backend/internal/util/files/cleaner.go b/backend/internal/util/files/cleaner.go new file mode 100644 index 0000000..941ee01 --- /dev/null +++ b/backend/internal/util/files/cleaner.go @@ -0,0 +1,7 @@ +package files_utils + +import "os" + +func CleanFolder(folder string) error { + return os.RemoveAll(folder) +} diff --git a/backend/internal/util/logger/logger.go b/backend/internal/util/logger/logger.go new file mode 100644 index 0000000..6b898c9 --- /dev/null +++ b/backend/internal/util/logger/logger.go @@ -0,0 +1,48 @@ +package logger + +import ( + "log/slog" + "os" + "sync" + "time" +) + +var ( + loggerInstance *slog.Logger + once sync.Once +) + +// GetLogger returns a singleton slog.Logger that logs to the console +func GetLogger() *slog.Logger { + once.Do(func() { + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + a.Value = slog.StringValue(time.Now().Format("2006/01/02 15:04:05")) + } + + if a.Key == slog.MessageKey { + // Format the message to match the desired output format + return slog.Attr{ + Key: slog.MessageKey, + Value: slog.StringValue(a.Value.String()), + } + } + + // Remove level and other attributes to get clean output + if a.Key == slog.LevelKey { + return slog.Attr{} + } + + return a + }, + }) + + loggerInstance = slog.New(handler) + + loggerInstance.Info("Text structured logger initialized") + }) + + return loggerInstance +} diff --git a/backend/internal/util/tools/enums.go b/backend/internal/util/tools/enums.go new file mode 100644 index 0000000..2035237 --- /dev/null +++ b/backend/internal/util/tools/enums.go @@ -0,0 +1,18 @@ +package tools + +type PostgresqlVersion string + +const ( + PostgresqlVersion13 PostgresqlVersion = "13" + PostgresqlVersion14 PostgresqlVersion = "14" + PostgresqlVersion15 PostgresqlVersion = "15" + PostgresqlVersion16 PostgresqlVersion = "16" + PostgresqlVersion17 PostgresqlVersion = "17" +) + +type PostgresqlExecutable string + +const ( + PostgresqlExecutablePgDump PostgresqlExecutable = "pg_dump" + PostgresqlExecutablePsql PostgresqlExecutable = "psql" +) diff --git a/backend/internal/util/tools/postgresql.go b/backend/internal/util/tools/postgresql.go new file mode 100644 index 0000000..5ea599c --- /dev/null +++ b/backend/internal/util/tools/postgresql.go @@ -0,0 +1,164 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + env_utils "postgresus-backend/internal/util/env" + "postgresus-backend/internal/util/logger" +) + +var log = logger.GetLogger() + +// GetPostgresqlExecutable returns the full path to a specific PostgreSQL executable +// for the given version. Common executables include: pg_dump, psql, etc. +// On Windows, automatically appends .exe extension. +func GetPostgresqlExecutable( + version PostgresqlVersion, + executable PostgresqlExecutable, + envMode env_utils.EnvMode, + postgresesInstallDir string, +) string { + basePath := getPostgresqlBasePath(version, envMode, postgresesInstallDir) + executableName := string(executable) + + // Add .exe extension on Windows + if runtime.GOOS == "windows" { + executableName += ".exe" + } + + return filepath.Join(basePath, executableName) +} + +// VerifyPostgresesInstallation verifies that PostgreSQL versions 13-17 are installed +// in the current environment. Each version should be installed with the required +// client tools (pg_dump, psql) available. +// In development: ./tools/postgresql/postgresql-{VERSION}/bin +// In production: /usr/pgsql-{VERSION}/bin +func VerifyPostgresesInstallation(envMode env_utils.EnvMode, postgresesInstallDir string) { + versions := []PostgresqlVersion{ + PostgresqlVersion13, + PostgresqlVersion14, + PostgresqlVersion15, + PostgresqlVersion16, + PostgresqlVersion17, + } + + requiredCommands := []PostgresqlExecutable{ + PostgresqlExecutablePgDump, + PostgresqlExecutablePsql, + } + + for _, version := range versions { + binDir := getPostgresqlBasePath(version, envMode, postgresesInstallDir) + + log.Info( + "Verifying PostgreSQL installation", + "version", + string(version), + "path", + binDir, + ) + + if _, err := os.Stat(binDir); os.IsNotExist(err) { + if envMode == env_utils.EnvModeDevelopment { + log.Error( + "PostgreSQL bin directory not found. Make sure PostgreSQL is installed. Read ./tools/readme.md for details", + "version", + string(version), + "path", + binDir, + ) + } else { + log.Error( + "PostgreSQL bin directory not found. Please ensure PostgreSQL client tools are installed.", + "version", + string(version), + "path", + binDir, + ) + } + os.Exit(1) + } + + for _, cmd := range requiredCommands { + cmdPath := GetPostgresqlExecutable( + version, + cmd, + envMode, + postgresesInstallDir, + ) + + log.Info( + "Checking for PostgreSQL command", + "command", + cmd, + "version", + string(version), + "path", + cmdPath, + ) + + if _, err := os.Stat(cmdPath); os.IsNotExist(err) { + if envMode == env_utils.EnvModeDevelopment { + log.Error( + "PostgreSQL command not found. Make sure PostgreSQL is installed. Read ./tools/readme.md for details", + "command", + cmd, + "version", + string(version), + "path", + cmdPath, + ) + } else { + log.Error( + "PostgreSQL command not found. Please ensure PostgreSQL client tools are properly installed.", + "command", + cmd, + "version", + string(version), + "path", + cmdPath, + ) + } + os.Exit(1) + } + + log.Info( + "PostgreSQL command found", + "command", + cmd, + "version", + string(version), + ) + } + + log.Info( + "Installation of PostgreSQL verified", + "version", + string(version), + "path", + binDir, + ) + } + + log.Info("All PostgreSQL version-specific client tools verification completed successfully!") +} + +func getPostgresqlBasePath( + version PostgresqlVersion, + envMode env_utils.EnvMode, + postgresesInstallDir string, +) string { + if envMode == env_utils.EnvModeDevelopment { + return filepath.Join( + postgresesInstallDir, + fmt.Sprintf("postgresql-%s", string(version)), + "bin", + ) + } else { + return fmt.Sprintf("/usr/pgsql-%s/bin", string(version)) + } +} diff --git a/backend/migrations/20250605090323_init.sql b/backend/migrations/20250605090323_init.sql new file mode 100644 index 0000000..5438250 --- /dev/null +++ b/backend/migrations/20250605090323_init.sql @@ -0,0 +1,260 @@ +-- +goose Up +-- +goose StatementBegin + +-- Create users table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + hashed_password TEXT NOT NULL, + password_creation_time TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + role TEXT NOT NULL +); + +CREATE UNIQUE INDEX users_email_idx ON users (email); + +-- Create secret keys table +CREATE TABLE secret_keys ( + secret TEXT NOT NULL +); + +CREATE UNIQUE INDEX secret_keys_secret_idx ON secret_keys (secret); + +-- Create notifiers table +CREATE TABLE notifiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + notifier_type VARCHAR(50) NOT NULL, + last_send_error TEXT +); + +CREATE INDEX idx_notifiers_user_id ON notifiers (user_id); + +-- Create telegram notifiers table +CREATE TABLE telegram_notifiers ( + notifier_id UUID PRIMARY KEY, + bot_token TEXT NOT NULL, + target_chat_id TEXT NOT NULL +); + +ALTER TABLE telegram_notifiers + ADD CONSTRAINT fk_telegram_notifiers_notifier + FOREIGN KEY (notifier_id) + REFERENCES notifiers (id) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + +-- Create email notifiers table +CREATE TABLE email_notifiers ( + notifier_id UUID PRIMARY KEY, + target_email VARCHAR(255) NOT NULL, + smtp_host VARCHAR(255) NOT NULL, + smtp_port INTEGER NOT NULL, + smtp_user VARCHAR(255) NOT NULL, + smtp_password VARCHAR(255) NOT NULL +); + +ALTER TABLE email_notifiers + ADD CONSTRAINT fk_email_notifiers_notifier + FOREIGN KEY (notifier_id) + REFERENCES notifiers (id) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + +-- Create storages table +CREATE TABLE storages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + last_save_error TEXT +); + +CREATE INDEX idx_storages_user_id ON storages (user_id); + +-- Create local storages table +CREATE TABLE local_storages ( + storage_id UUID PRIMARY KEY +); + +ALTER TABLE local_storages + ADD CONSTRAINT fk_local_storages_storage + FOREIGN KEY (storage_id) + REFERENCES storages (id) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + +-- Create S3 storages table +CREATE TABLE s3_storages ( + storage_id UUID PRIMARY KEY, + s3_bucket TEXT NOT NULL, + s3_region TEXT NOT NULL, + s3_access_key TEXT NOT NULL, + s3_secret_key TEXT NOT NULL, + s3_endpoint TEXT +); + +ALTER TABLE s3_storages + ADD CONSTRAINT fk_s3_storages_storage + FOREIGN KEY (storage_id) + REFERENCES storages (id) + ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + +-- Create intervals table +CREATE TABLE intervals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + interval TEXT NOT NULL, + time_of_day TEXT, + weekday INT, + day_of_month INT +); + +-- Create databases table +CREATE TABLE databases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + backup_interval_id UUID NOT NULL, + storage_id UUID NOT NULL, + store_period TEXT NOT NULL, + last_backup_time TIMESTAMPTZ, + last_backup_error_message TEXT, + send_notifications_on TEXT NOT NULL DEFAULT '' +); + +ALTER TABLE databases + ADD CONSTRAINT fk_databases_backup_interval_id + FOREIGN KEY (backup_interval_id) + REFERENCES intervals (id) + ON DELETE RESTRICT; + +ALTER TABLE databases + ADD CONSTRAINT fk_databases_user_id + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +ALTER TABLE databases + ADD CONSTRAINT fk_databases_storage_id + FOREIGN KEY (storage_id) + REFERENCES storages (id) + ON DELETE RESTRICT; + +CREATE INDEX idx_databases_user_id ON databases (user_id); +CREATE INDEX idx_databases_storage_id ON databases (storage_id); + +-- Create postgresql databases table +CREATE TABLE postgresql_databases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + database_id UUID, + version TEXT NOT NULL, + host TEXT NOT NULL, + port INT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + database TEXT, + is_https BOOLEAN NOT NULL DEFAULT FALSE, + cpu_count INT NOT NULL, + restore_id UUID +); + +ALTER TABLE postgresql_databases + ADD CONSTRAINT uk_postgresql_databases_database_id + UNIQUE (database_id); + +CREATE INDEX idx_postgresql_databases_database_id ON postgresql_databases (database_id); +CREATE INDEX idx_postgresql_databases_restore_id ON postgresql_databases (restore_id); + +-- Create database notifiers association table +CREATE TABLE database_notifiers ( + database_id UUID NOT NULL, + notifier_id UUID NOT NULL, + PRIMARY KEY (database_id, notifier_id) +); + +ALTER TABLE database_notifiers + ADD CONSTRAINT fk_database_notifiers_database_id + FOREIGN KEY (database_id) + REFERENCES databases (id) + ON DELETE CASCADE; + +ALTER TABLE database_notifiers + ADD CONSTRAINT fk_database_notifiers_notifier_id + FOREIGN KEY (notifier_id) + REFERENCES notifiers (id) + ON DELETE RESTRICT; + +CREATE INDEX idx_database_notifiers_database_id ON database_notifiers (database_id); + +-- Create backups table +CREATE TABLE backups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + database_id UUID NOT NULL, + storage_id UUID NOT NULL, + status TEXT NOT NULL, + fail_message TEXT, + backup_size_mb DOUBLE PRECISION NOT NULL DEFAULT 0, + backup_duration_ms BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE backups + ADD CONSTRAINT fk_backups_database_id + FOREIGN KEY (database_id) + REFERENCES databases (id) + ON DELETE CASCADE; + +ALTER TABLE backups + ADD CONSTRAINT fk_backups_storage_id + FOREIGN KEY (storage_id) + REFERENCES storages (id) + ON DELETE RESTRICT; + +CREATE INDEX idx_backups_database_id_created_at ON backups (database_id, created_at DESC); +CREATE INDEX idx_backups_status_created_at ON backups (status, created_at DESC); + +-- Create restores table +CREATE TABLE restores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + backup_id UUID NOT NULL, + status TEXT NOT NULL, + fail_message TEXT, + restore_duration_ms BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE restores + ADD CONSTRAINT fk_restores_backup_id + FOREIGN KEY (backup_id) + REFERENCES backups (id) + ON DELETE CASCADE; + +ALTER TABLE postgresql_databases + ADD CONSTRAINT fk_postgresql_databases_restore_id + FOREIGN KEY (restore_id) + REFERENCES restores (id) + ON DELETE CASCADE; + +CREATE INDEX idx_restores_backup_id_created_at ON restores (backup_id, created_at); +CREATE INDEX idx_restores_status_created_at ON restores (status, created_at); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS restores; +DROP TABLE IF EXISTS backups; +DROP TABLE IF EXISTS database_notifiers; +DROP TABLE IF EXISTS postgresql_databases; +DROP TABLE IF EXISTS databases; +DROP TABLE IF EXISTS intervals; +DROP TABLE IF EXISTS s3_storages; +DROP TABLE IF EXISTS local_storages; +DROP TABLE IF EXISTS storages; +DROP TABLE IF EXISTS email_notifiers; +DROP TABLE IF EXISTS telegram_notifiers; +DROP TABLE IF EXISTS notifiers; +DROP TABLE IF EXISTS secret_keys; +DROP TABLE IF EXISTS users; + +-- +goose StatementEnd diff --git a/backend/tools/.gitignore b/backend/tools/.gitignore new file mode 100644 index 0000000..6f7d947 --- /dev/null +++ b/backend/tools/.gitignore @@ -0,0 +1,2 @@ +postgresql +downloads \ No newline at end of file diff --git a/backend/tools/download_linux.sh b/backend/tools/download_linux.sh new file mode 100644 index 0000000..4083e7f --- /dev/null +++ b/backend/tools/download_linux.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -e # Exit on any error + +echo "Installing PostgreSQL client tools versions 13-17 for Linux (Debian/Ubuntu)..." +echo + +# Check if running on supported system +if ! command -v apt-get &> /dev/null; then + echo "Error: This script requires apt-get (Debian/Ubuntu-like system)" + exit 1 +fi + +# Check if running as root or with sudo +if [[ $EUID -eq 0 ]]; then + SUDO="" +else + SUDO="sudo" + echo "This script requires sudo privileges to install packages." +fi + +# Create postgresql directory +mkdir -p postgresql + +# Get absolute path +POSTGRES_DIR="$(pwd)/postgresql" + +echo "Installing PostgreSQL client tools to: $POSTGRES_DIR" +echo + +# Add PostgreSQL official APT repository +echo "Adding PostgreSQL official APT repository..." +$SUDO apt-get update -qq +$SUDO apt-get install -y wget ca-certificates + +# Add GPG key +wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | $SUDO apt-key add - + +# Add repository +echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list + +# Update package list +echo "Updating package list..." +$SUDO apt-get update -qq + +# Install client tools for each version +versions="13 14 15 16 17" + +for version in $versions; do + echo "Installing PostgreSQL $version client tools..." + + # Install client tools only + $SUDO apt-get install -y postgresql-client-$version + + # Create version-specific directory and symlinks + version_dir="$POSTGRES_DIR/postgresql-$version" + mkdir -p "$version_dir/bin" + + # Create symlinks to the installed binaries + if [ -f "/usr/bin/pg_dump" ]; then + # If multiple versions, binaries are usually named with version suffix + if [ -f "/usr/bin/pg_dump-$version" ]; then + ln -sf "/usr/bin/pg_dump-$version" "$version_dir/bin/pg_dump" + ln -sf "/usr/bin/pg_dumpall-$version" "$version_dir/bin/pg_dumpall" + ln -sf "/usr/bin/psql-$version" "$version_dir/bin/psql" + ln -sf "/usr/bin/pg_restore-$version" "$version_dir/bin/pg_restore" + ln -sf "/usr/bin/createdb-$version" "$version_dir/bin/createdb" + ln -sf "/usr/bin/dropdb-$version" "$version_dir/bin/dropdb" + else + # Fallback to non-versioned names (latest version) + ln -sf "/usr/bin/pg_dump" "$version_dir/bin/pg_dump" + ln -sf "/usr/bin/pg_dumpall" "$version_dir/bin/pg_dumpall" + ln -sf "/usr/bin/psql" "$version_dir/bin/psql" + ln -sf "/usr/bin/pg_restore" "$version_dir/bin/pg_restore" + ln -sf "/usr/bin/createdb" "$version_dir/bin/createdb" + ln -sf "/usr/bin/dropdb" "$version_dir/bin/dropdb" + fi + + echo "PostgreSQL $version client tools installed successfully" + else + echo "Warning: PostgreSQL $version client tools may not have installed correctly" + fi + echo +done + +echo "Installation completed!" +echo "PostgreSQL client tools are available in: $POSTGRES_DIR" +echo + +# List installed versions +echo "Installed PostgreSQL client versions:" +for version in $versions; do + version_dir="$POSTGRES_DIR/postgresql-$version" + if [ -f "$version_dir/bin/pg_dump" ]; then + echo " postgresql-$version: $version_dir/bin/" + fi +done + +echo +echo "Usage example:" +echo " $POSTGRES_DIR/postgresql-15/bin/pg_dump --version" \ No newline at end of file diff --git a/backend/tools/download_macos.sh b/backend/tools/download_macos.sh new file mode 100644 index 0000000..248af81 --- /dev/null +++ b/backend/tools/download_macos.sh @@ -0,0 +1,143 @@ +#!/bin/bash + +set -e # Exit on any error + +echo "Installing PostgreSQL client tools versions 13-17 for MacOS..." +echo + +# Check if Homebrew is installed +if ! command -v brew &> /dev/null; then + echo "Error: This script requires Homebrew to be installed." + echo "Install Homebrew from: https://brew.sh/" + exit 1 +fi + +# Create postgresql directory +mkdir -p postgresql + +# Get absolute path +POSTGRES_DIR="$(pwd)/postgresql" + +echo "Installing PostgreSQL client tools to: $POSTGRES_DIR" +echo + +# Update Homebrew +echo "Updating Homebrew..." +brew update + +# Install build dependencies +echo "Installing build dependencies..." +brew install wget openssl readline zlib + +# PostgreSQL source URLs +declare -A PG_URLS=( + ["13"]="https://ftp.postgresql.org/pub/source/v13.16/postgresql-13.16.tar.gz" + ["14"]="https://ftp.postgresql.org/pub/source/v14.13/postgresql-14.13.tar.gz" + ["15"]="https://ftp.postgresql.org/pub/source/v15.8/postgresql-15.8.tar.gz" + ["16"]="https://ftp.postgresql.org/pub/source/v16.4/postgresql-16.4.tar.gz" + ["17"]="https://ftp.postgresql.org/pub/source/v17.0/postgresql-17.0.tar.gz" +) + +# Create temporary build directory +BUILD_DIR="/tmp/postgresql_build_$$" +mkdir -p "$BUILD_DIR" + +echo "Using temporary build directory: $BUILD_DIR" +echo + +# Function to build PostgreSQL client tools +build_postgresql_client() { + local version=$1 + local url=$2 + local version_dir="$POSTGRES_DIR/postgresql-$version" + + echo "Building PostgreSQL $version client tools..." + + # Skip if already exists + if [ -f "$version_dir/bin/pg_dump" ]; then + echo "PostgreSQL $version already installed, skipping..." + return + fi + + cd "$BUILD_DIR" + + # Download source + echo " Downloading PostgreSQL $version source..." + wget -q "$url" -O "postgresql-$version.tar.gz" + + # Extract + echo " Extracting source..." + tar -xzf "postgresql-$version.tar.gz" + cd "postgresql-$version"* + + # Configure (client tools only) + echo " Configuring build..." + ./configure \ + --prefix="$version_dir" \ + --with-openssl \ + --with-readline \ + --without-zlib \ + --disable-server \ + --disable-docs \ + --quiet + + # Build client tools only + echo " Building client tools (this may take a few minutes)..." + make -s -C src/bin install + make -s -C src/include install + make -s -C src/interfaces install + + # Verify installation + if [ -f "$version_dir/bin/pg_dump" ]; then + echo " PostgreSQL $version client tools installed successfully" + + # Test the installation + local pg_version=$("$version_dir/bin/pg_dump" --version | cut -d' ' -f3) + echo " Verified version: $pg_version" + else + echo " Warning: PostgreSQL $version may not have installed correctly" + fi + + # Clean up source + cd "$BUILD_DIR" + rm -rf "postgresql-$version"* + + echo +} + +# Build each version +versions="13 14 15 16 17" + +for version in $versions; do + url=${PG_URLS[$version]} + if [ -n "$url" ]; then + build_postgresql_client "$version" "$url" + else + echo "Warning: No URL defined for PostgreSQL $version" + fi +done + +# Clean up build directory +echo "Cleaning up build directory..." +rm -rf "$BUILD_DIR" + +echo "Installation completed!" +echo "PostgreSQL client tools are available in: $POSTGRES_DIR" +echo + +# List installed versions +echo "Installed PostgreSQL client versions:" +for version in $versions; do + version_dir="$POSTGRES_DIR/postgresql-$version" + if [ -f "$version_dir/bin/pg_dump" ]; then + pg_version=$("$version_dir/bin/pg_dump" --version | cut -d' ' -f3) + echo " postgresql-$version ($pg_version): $version_dir/bin/" + fi +done + +echo +echo "Usage example:" +echo " $POSTGRES_DIR/postgresql-15/bin/pg_dump --version" +echo +echo "To add a specific version to your PATH temporarily:" +echo " export PATH=\"$POSTGRES_DIR/postgresql-15/bin:\$PATH\"" \ No newline at end of file diff --git a/backend/tools/download_windows.bat b/backend/tools/download_windows.bat new file mode 100644 index 0000000..61f522f --- /dev/null +++ b/backend/tools/download_windows.bat @@ -0,0 +1,101 @@ +@echo off +setlocal enabledelayedexpansion + +echo Downloading and installing PostgreSQL versions 13-17 for Windows... +echo. + +:: Create downloads and postgresql directories if they don't exist +if not exist "downloads" mkdir downloads +if not exist "postgresql" mkdir postgresql + +:: Get the absolute path to the postgresql directory +set "POSTGRES_DIR=%cd%\postgresql" + +cd downloads + +:: PostgreSQL download URLs for Windows x64 +set "BASE_URL=https://get.enterprisedb.com/postgresql" + +:: Define versions and their corresponding download URLs +set "PG13_URL=%BASE_URL%/postgresql-13.16-1-windows-x64.exe" +set "PG14_URL=%BASE_URL%/postgresql-14.13-1-windows-x64.exe" +set "PG15_URL=%BASE_URL%/postgresql-15.8-1-windows-x64.exe" +set "PG16_URL=%BASE_URL%/postgresql-16.4-1-windows-x64.exe" +set "PG17_URL=%BASE_URL%/postgresql-17.0-1-windows-x64.exe" + +:: Array of versions +set "versions=13 14 15 16 17" + +:: Download and install each version +for %%v in (%versions%) do ( + echo Processing PostgreSQL %%v... + set "filename=postgresql-%%v-windows-x64.exe" + set "install_dir=%POSTGRES_DIR%\postgresql-%%v" + + :: Check if already installed + if exist "!install_dir!" ( + echo PostgreSQL %%v already installed, skipping... + ) else ( + :: Download if not exists + if not exist "!filename!" ( + echo Downloading PostgreSQL %%v... + powershell -Command "& {$ProgressPreference = 'SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $uri = '!PG%%v_URL!'; $file = '!filename!'; $client = New-Object System.Net.WebClient; $client.Headers.Add('User-Agent', 'Mozilla/5.0'); $client.add_DownloadProgressChanged({param($s,$e) $pct = $e.ProgressPercentage; $recv = [math]::Round($e.BytesReceived/1MB,1); $total = [math]::Round($e.TotalBytesToReceive/1MB,1); Write-Host ('{0}%% - {1} MB / {2} MB' -f $pct, $recv, $total) -NoNewline; Write-Host ('`r') -NoNewline}); try {Write-Host 'Starting download...'; $client.DownloadFile($uri, $file); Write-Host ''; Write-Host 'Download completed!'} finally {$client.Dispose()}}" + + if !errorlevel! neq 0 ( + echo Failed to download PostgreSQL %%v + goto :next_version + ) + echo PostgreSQL %%v downloaded successfully + ) else ( + echo PostgreSQL %%v already downloaded + ) + + :: Install PostgreSQL client tools only + echo Installing PostgreSQL %%v client tools to !install_dir!... + echo This may take up to 10 minutes even on powerful machines, please wait... + + :: First try: Install with component selection + start /wait "" "!filename!" --mode unattended --unattendedmodeui none --prefix "!install_dir!" --disable-components server,pgAdmin,stackbuilder --enable-components commandlinetools + + :: Check if installation actually worked by looking for pg_dump.exe + if exist "!install_dir!\bin\pg_dump.exe" ( + echo PostgreSQL %%v client tools installed successfully + ) else ( + echo Component selection failed, trying full installation... + echo This may take up to 10 minutes even on powerful machines, please wait... + :: Fallback: Install everything but without starting services + start /wait "" "!filename!" --mode unattended --unattendedmodeui none --prefix "!install_dir!" --datadir "!install_dir!\data" --servicename "postgresql-%%v" --serviceaccount "NetworkService" --superpassword "postgres" --serverport 543%%v --extract-only 1 + + :: Check again + if exist "!install_dir!\bin\pg_dump.exe" ( + echo PostgreSQL %%v installed successfully + ) else ( + echo Failed to install PostgreSQL %%v - No files found in installation directory + echo Checking what was created: + if exist "!install_dir!" ( + powershell -Command "Get-ChildItem '!install_dir!' -Recurse | Select-Object -First 10 | ForEach-Object { $_.FullName }" + ) else ( + echo Installation directory was not created + ) + ) + ) + ) + + :next_version + echo. +) + +echo. +echo Installation process completed! +echo PostgreSQL versions are installed in: %POSTGRES_DIR% +echo. + +:: List installed versions +echo Installed PostgreSQL versions: +if exist "postgresql" ( + dir /b "postgresql\postgresql-*" 2>nul +) else ( + echo No PostgreSQL installations found +) + +pause diff --git a/backend/tools/readme.md b/backend/tools/readme.md new file mode 100644 index 0000000..18327ab --- /dev/null +++ b/backend/tools/readme.md @@ -0,0 +1,90 @@ +This directory is needed only for development. + +We have to download and install all the PostgreSQL versions from 13 to 17 locally. +This is needed so we can call pg_dump, pg_dumpall, etc. on each version of the PostgreSQL database. + +You do not need to install PostgreSQL fully with all the components. +We only need the client tools (pg_dump, pg_dumpall, psql, etc.) for each version. + +We have to install the following: + +- PostgreSQL 13 +- PostgreSQL 14 +- PostgreSQL 15 +- PostgreSQL 16 +- PostgreSQL 17 + +## Installation + +Run the appropriate download script for your platform: + +### Windows + +```cmd +download_windows.bat +``` + +### Linux (Debian/Ubuntu) + +```bash +chmod +x download_linux.sh +./download_linux.sh +``` + +### MacOS + +```bash +chmod +x download_macos.sh +./download_macos.sh +``` + +## Platform-Specific Notes + +### Windows + +- Downloads official PostgreSQL installers from EnterpriseDB +- Installs client tools only (no server components) +- May require administrator privileges during installation + +### Linux (Debian/Ubuntu) + +- Uses the official PostgreSQL APT repository +- Requires sudo privileges to install packages +- Creates symlinks in version-specific directories for consistency + +### MacOS + +- Requires Homebrew to be installed +- Compiles PostgreSQL from source (client tools only) +- Takes longer than other platforms due to compilation + +## Manual Installation + +If something goes wrong with the automated scripts, install manually. +The final directory structure should match: + +``` +./tools/postgresql/postgresql-{version}/bin/pg_dump +./tools/postgresql/postgresql-{version}/bin/pg_dumpall +./tools/postgresql/postgresql-{version}/bin/psql +``` + +For example: + +- `./tools/postgresql/postgresql-13/bin/pg_dump` +- `./tools/postgresql/postgresql-14/bin/pg_dump` +- `./tools/postgresql/postgresql-15/bin/pg_dump` +- `./tools/postgresql/postgresql-16/bin/pg_dump` +- `./tools/postgresql/postgresql-17/bin/pg_dump` + +## Usage + +After installation, you can use version-specific tools: + +```bash +# Windows +./postgresql/postgresql-15/bin/pg_dump.exe --version + +# Linux/MacOS +./postgresql/postgresql-15/bin/pg_dump --version +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..699c1db --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3" + +services: + postgresus-frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "4006:4006" + + postgresus-backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "4005:4005" + volumes: + - ./postgresus-data:/postgresus-data + + postgresus-db: + env_file: + - .env + image: postgres:17 + environment: + - POSTGRES_DB=${DB_NAME} + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + volumes: + - ./pgdata:/var/lib/postgresql/data + container_name: postgresus-db + command: -p 5437 + shm_size: 10gb diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.prettierrc.js b/frontend/.prettierrc.js new file mode 100644 index 0000000..db49ae9 --- /dev/null +++ b/frontend/.prettierrc.js @@ -0,0 +1,24 @@ +export default { + semi: true, + trailingComma: 'all', + singleQuote: true, + printWidth: 100, + tabWidth: 2, + arrowParens: 'always', + importOrder: [ + '', + '.+.(svg|png|jpeg|jpg|webp|css)$', + '^@/constants', + 'app/(.*)$', + '1_pages/(.*)$', + '2_widgets/(.*)$', + '3_features/(.*)$', + '4_entity/(.*)$', + '5_shared/(.*)$', + '(presentation|hooks|components)/(.*)$', + '^[./]', + ], + importOrderSeparation: true, + importOrderSortSpecifiers: true, + plugins: ['@trivago/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'], +}; diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..eda55d9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:18-alpine + +EXPOSE 4006 + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci && npm install -g serve + +COPY . . + +RUN npm run build + +CMD ["serve", "-s", "dist", "-l", "4006"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..9aa3e7c --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,54 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config({ + extends: [ + // Remove ...tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}); +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactDom from 'eslint-plugin-react-dom'; +import reactX from 'eslint-plugin-react-x'; + +export default tseslint.config({ + plugins: { + // Add the react-x and react-dom plugins + 'react-x': reactX, + 'react-dom': reactDom, + }, + rules: { + // other rules... + // Enable its recommended typescript rules + ...reactX.configs['recommended-typescript'].rules, + ...reactDom.configs.recommended.rules, + }, +}); +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..9d6e147 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,35 @@ +import js from '@eslint/js'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + react: react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + ...react.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + 'react/react-in-jsx-scope': 'off', + 'react-hooks/exhaustive-deps': 'off', + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2575d0a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,22 @@ + + + + + + + + Postgresus - PostgreSQL backups + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..396ceec --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6990 @@ +{ + "name": "postgresus-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "postgresus-frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.1.7", + "antd": "^5.25.1", + "dayjs": "^1.11.13", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.6.0", + "tailwindcss": "^4.1.7" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "prettier": "^3.5.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.0.tgz", + "integrity": "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.23.0.tgz", + "integrity": "sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", + "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helpers": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", + "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.14.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", + "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.0.0.tgz", + "integrity": "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.2.6.tgz", + "integrity": "sha512-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz", + "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz", + "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz", + "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz", + "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz", + "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz", + "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz", + "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz", + "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz", + "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz", + "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz", + "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz", + "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz", + "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz", + "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz", + "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz", + "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz", + "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz", + "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz", + "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz", + "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz", + "integrity": "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.7" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.7.tgz", + "integrity": "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.7", + "@tailwindcss/oxide-darwin-arm64": "4.1.7", + "@tailwindcss/oxide-darwin-x64": "4.1.7", + "@tailwindcss/oxide-freebsd-x64": "4.1.7", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", + "@tailwindcss/oxide-linux-x64-musl": "4.1.7", + "@tailwindcss/oxide-wasm32-wasi": "4.1.7", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.7" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.7.tgz", + "integrity": "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.7.tgz", + "integrity": "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.7.tgz", + "integrity": "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.7.tgz", + "integrity": "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.7.tgz", + "integrity": "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.7.tgz", + "integrity": "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.7.tgz", + "integrity": "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.7.tgz", + "integrity": "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.7.tgz", + "integrity": "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.7.tgz", + "integrity": "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.9", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz", + "integrity": "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.7.tgz", + "integrity": "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.7.tgz", + "integrity": "sha512-tYa2fO3zDe41I7WqijyVbRd8oWT0aEID1Eokz5hMT6wShLIHj3yvwj9XbfuloHP9glZ6H+aG2AN/+ZrxJ1Y5RQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.7", + "@tailwindcss/oxide": "4.1.7", + "tailwindcss": "4.1.7" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6" + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz", + "integrity": "sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.7", + "@babel/traverse": "^7.26.7", + "@babel/types": "^7.26.7", + "javascript-natural-sort": "^0.7.1", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">18.12" + }, + "peerDependencies": { + "@vue/compiler-sfc": "3.x", + "prettier": "2.x - 3.x", + "prettier-plugin-svelte": "3.x", + "svelte": "4.x || 5.x" + }, + "peerDependenciesMeta": { + "@vue/compiler-sfc": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + }, + "svelte": { + "optional": true + } + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "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/react": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", + "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", + "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", + "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/type-utils": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.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.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", + "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "debug": "^4.3.4" + }, + "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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", + "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", + "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", + "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "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.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", + "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.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 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", + "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1" + }, + "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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", + "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.1", + "eslint-visitor-keys": "^4.2.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-react": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", + "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.10", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "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.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.25.1.tgz", + "integrity": "sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.0", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.0.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.2.6", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.2.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.0", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.7", + "rc-slider": "~11.1.8", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.50.4", + "rc-tabs": "~15.6.1", + "rc-textarea": "~1.10.0", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.9.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "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/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "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/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.155", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", + "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": "9.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", + "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.27.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "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", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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/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-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", + "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "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/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "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/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.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/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "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": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "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.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.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/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.2.0.tgz", + "integrity": "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.0.tgz", + "integrity": "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.4.1.tgz", + "integrity": "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.0.tgz", + "integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.8.tgz", + "integrity": "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.50.5", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.5.tgz", + "integrity": "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.6.1.tgz", + "integrity": "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.0.tgz", + "integrity": "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.9.0.tgz", + "integrity": "sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/rc-virtual-list": { + "version": "3.18.6", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.18.6.tgz", + "integrity": "sha512-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", + "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", + "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.41.0", + "@rollup/rollup-android-arm64": "4.41.0", + "@rollup/rollup-darwin-arm64": "4.41.0", + "@rollup/rollup-darwin-x64": "4.41.0", + "@rollup/rollup-freebsd-arm64": "4.41.0", + "@rollup/rollup-freebsd-x64": "4.41.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.41.0", + "@rollup/rollup-linux-arm-musleabihf": "4.41.0", + "@rollup/rollup-linux-arm64-gnu": "4.41.0", + "@rollup/rollup-linux-arm64-musl": "4.41.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.41.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0", + "@rollup/rollup-linux-riscv64-gnu": "4.41.0", + "@rollup/rollup-linux-riscv64-musl": "4.41.0", + "@rollup/rollup-linux-s390x-gnu": "4.41.0", + "@rollup/rollup-linux-x64-gnu": "4.41.0", + "@rollup/rollup-linux-x64-musl": "4.41.0", + "@rollup/rollup-win32-arm64-msvc": "4.41.0", + "@rollup/rollup-win32-ia32-msvc": "4.41.0", + "@rollup/rollup-win32-x64-msvc": "4.41.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", + "integrity": "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "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/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.1.tgz", + "integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.32.1", + "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/utils": "8.32.1" + }, + "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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "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/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "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..0456b2a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "postgresus-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.7", + "antd": "^5.25.1", + "dayjs": "^1.11.13", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.6.0", + "tailwindcss": "^4.1.7" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "prettier": "^3.5.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..3d49181 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/icons/databases/postgresql.svg b/frontend/public/icons/databases/postgresql.svg new file mode 100644 index 0000000..d1eefea --- /dev/null +++ b/frontend/public/icons/databases/postgresql.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/icons/menu/database-gray.svg b/frontend/public/icons/menu/database-gray.svg new file mode 100644 index 0000000..bd96e40 --- /dev/null +++ b/frontend/public/icons/menu/database-gray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/menu/database-white.svg b/frontend/public/icons/menu/database-white.svg new file mode 100644 index 0000000..d2a8262 --- /dev/null +++ b/frontend/public/icons/menu/database-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/notifier-gray.svg b/frontend/public/icons/menu/notifier-gray.svg new file mode 100644 index 0000000..6a2f40e --- /dev/null +++ b/frontend/public/icons/menu/notifier-gray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/icons/menu/notifier-white.svg b/frontend/public/icons/menu/notifier-white.svg new file mode 100644 index 0000000..7823d45 --- /dev/null +++ b/frontend/public/icons/menu/notifier-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/storage-gray.svg b/frontend/public/icons/menu/storage-gray.svg new file mode 100644 index 0000000..aad0714 --- /dev/null +++ b/frontend/public/icons/menu/storage-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/menu/storage-white.svg b/frontend/public/icons/menu/storage-white.svg new file mode 100644 index 0000000..4adc601 --- /dev/null +++ b/frontend/public/icons/menu/storage-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/notifiers/email.svg b/frontend/public/icons/notifiers/email.svg new file mode 100644 index 0000000..957ca6f --- /dev/null +++ b/frontend/public/icons/notifiers/email.svg @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/frontend/public/icons/notifiers/telegram.svg b/frontend/public/icons/notifiers/telegram.svg new file mode 100644 index 0000000..b892f17 --- /dev/null +++ b/frontend/public/icons/notifiers/telegram.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/icons/pen-gray.svg b/frontend/public/icons/pen-gray.svg new file mode 100644 index 0000000..7e97868 --- /dev/null +++ b/frontend/public/icons/pen-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/icons/storages/local.svg b/frontend/public/icons/storages/local.svg new file mode 100644 index 0000000..05f4430 --- /dev/null +++ b/frontend/public/icons/storages/local.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/storages/s3.svg b/frontend/public/icons/storages/s3.svg new file mode 100644 index 0000000..a0bc00d --- /dev/null +++ b/frontend/public/icons/storages/s3.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/icons/warn-red.svg b/frontend/public/icons/warn-red.svg new file mode 100644 index 0000000..ee9742b --- /dev/null +++ b/frontend/public/icons/warn-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..8033938 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1fb2cc8 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import { BrowserRouter, Route } from 'react-router'; +import { Routes } from 'react-router'; + +import { userApi } from './entity/users'; +import { AuthPageComponent } from './pages/AuthPageComponent'; +import { MainScreenComponent } from './widgets/main/MainScreenComponent'; + +function App() { + const [isAuthorized, setIsAuthorized] = useState(false); + + useEffect(() => { + const isAuthorized = userApi.isAuthorized(); + setIsAuthorized(isAuthorized); + + userApi.addAuthListener(() => { + setIsAuthorized(userApi.isAuthorized()); + }); + }, []); + + return ( + + + : } /> + + + ); +} + +export default App; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 0000000..c7f7a22 --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,5 @@ +export function getApplicationServer() { + const origin = window.location.origin; + const url = new URL(origin); + return `${url.protocol}//${url.hostname}:4005`; +} diff --git a/frontend/src/entity/backups/api/backupsApi.ts b/frontend/src/entity/backups/api/backupsApi.ts new file mode 100644 index 0000000..6fcd568 --- /dev/null +++ b/frontend/src/entity/backups/api/backupsApi.ts @@ -0,0 +1,25 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { Backup } from '../model/Backup'; + +export const backupsApi = { + async getBackups(databaseId: string) { + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/backups?database_id=${databaseId}`, + ); + }, + + async makeBackup(databaseId: string) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify({ database_id: databaseId })); + return apiHelper.fetchPostJson<{ message: string }>( + `${getApplicationServer()}/api/v1/backups`, + requestOptions, + ); + }, + + async deleteBackup(id: string) { + return apiHelper.fetchDeleteRaw(`${getApplicationServer()}/api/v1/backups/${id}`); + }, +}; diff --git a/frontend/src/entity/backups/index.ts b/frontend/src/entity/backups/index.ts new file mode 100644 index 0000000..08e1b87 --- /dev/null +++ b/frontend/src/entity/backups/index.ts @@ -0,0 +1,3 @@ +export { backupsApi } from './api/backupsApi'; +export { BackupStatus } from './model/BackupStatus'; +export type { Backup } from './model/Backup'; diff --git a/frontend/src/entity/backups/model/Backup.ts b/frontend/src/entity/backups/model/Backup.ts new file mode 100644 index 0000000..758ceb4 --- /dev/null +++ b/frontend/src/entity/backups/model/Backup.ts @@ -0,0 +1,19 @@ +import type { Database } from '../../databases/model/Database'; +import type { Storage } from '../../storages'; +import { BackupStatus } from './BackupStatus'; + +export interface Backup { + id: string; + + database: Database; + storage: Storage; + + status: BackupStatus; + failMessage?: string; + + backupSizeMb: number; + + backupDurationMs: number; + + createdAt: Date; +} diff --git a/frontend/src/entity/backups/model/BackupStatus.ts b/frontend/src/entity/backups/model/BackupStatus.ts new file mode 100644 index 0000000..dbe7651 --- /dev/null +++ b/frontend/src/entity/backups/model/BackupStatus.ts @@ -0,0 +1,6 @@ +export enum BackupStatus { + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + DELETED = 'DELETED', +} diff --git a/frontend/src/entity/databases/api/databaseApi.ts b/frontend/src/entity/databases/api/databaseApi.ts new file mode 100644 index 0000000..e6d7a13 --- /dev/null +++ b/frontend/src/entity/databases/api/databaseApi.ts @@ -0,0 +1,86 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { Database } from '../model/Database'; + +export const databaseApi = { + async createDatabase(database: Database) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(database)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/databases/create`, + requestOptions, + ); + }, + + async updateDatabase(database: Database) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(database)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/databases/update`, + requestOptions, + ); + }, + + async getDatabase(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/databases/${id}`, + requestOptions, + ); + }, + + async getDatabases() { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/databases`, + requestOptions, + ); + }, + + async deleteDatabase(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchDeleteRaw( + `${getApplicationServer()}/api/v1/databases/${id}`, + requestOptions, + ); + }, + + async testDatabaseConnection(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/databases/${id}/test-connection`, + requestOptions, + ); + }, + + async testDatabaseConnectionDirect(database: Database) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(database)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/databases/test-connection-direct`, + requestOptions, + ); + }, + + async isNotifierUsing(notifierId: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper + .fetchGetJson<{ + isUsing: boolean; + }>( + `${getApplicationServer()}/api/v1/databases/notifier/${notifierId}/is-using`, + requestOptions, + ) + .then((res) => res.isUsing); + }, + + async isStorageUsing(storageId: string): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper + .fetchGetJson<{ + isUsing: boolean; + }>(`${getApplicationServer()}/api/v1/databases/storage/${storageId}/is-using`, requestOptions) + .then((res) => res.isUsing); + }, +}; diff --git a/frontend/src/entity/databases/index.ts b/frontend/src/entity/databases/index.ts new file mode 100644 index 0000000..23f7eda --- /dev/null +++ b/frontend/src/entity/databases/index.ts @@ -0,0 +1,6 @@ +export { databaseApi } from './api/databaseApi'; +export { type Database } from './model/Database'; +export { DatabaseType } from './model/DatabaseType'; +export { Period } from './model/Period'; +export { type PostgresqlDatabase } from './model/postgresql/PostgresqlDatabase'; +export { PostgresqlVersion } from './model/postgresql/PostgresqlVersion'; diff --git a/frontend/src/entity/databases/model/BackupNotificationType.ts b/frontend/src/entity/databases/model/BackupNotificationType.ts new file mode 100644 index 0000000..bc46d47 --- /dev/null +++ b/frontend/src/entity/databases/model/BackupNotificationType.ts @@ -0,0 +1,4 @@ +export enum BackupNotificationType { + BACKUP_FAILED = "BACKUP_FAILED", + BACKUP_SUCCESS = "BACKUP_SUCCESS", +} diff --git a/frontend/src/entity/databases/model/Database.ts b/frontend/src/entity/databases/model/Database.ts new file mode 100644 index 0000000..dab5b73 --- /dev/null +++ b/frontend/src/entity/databases/model/Database.ts @@ -0,0 +1,26 @@ +import type { Interval } from '../../intervals'; +import type { Notifier } from '../../notifiers'; +import type { BackupNotificationType } from './BackupNotificationType'; +import type { DatabaseType } from './DatabaseType'; +import type { Period } from './Period'; +import type { PostgresqlDatabase } from './postgresql/PostgresqlDatabase'; + +export interface Database { + id: string; + name: string; + + type: DatabaseType; + + backupInterval?: Interval; + storePeriod: Period; + + postgresql?: PostgresqlDatabase; + + storage: Storage; + + notifiers: Notifier[]; + sendNotificationsOn: BackupNotificationType[]; + + lastBackupTime?: Date; + lastBackupErrorMessage?: string; +} diff --git a/frontend/src/entity/databases/model/DatabaseType.ts b/frontend/src/entity/databases/model/DatabaseType.ts new file mode 100644 index 0000000..43c37a6 --- /dev/null +++ b/frontend/src/entity/databases/model/DatabaseType.ts @@ -0,0 +1,3 @@ +export enum DatabaseType { + POSTGRES = 'POSTGRES', +} diff --git a/frontend/src/entity/databases/model/Period.ts b/frontend/src/entity/databases/model/Period.ts new file mode 100644 index 0000000..c785ba3 --- /dev/null +++ b/frontend/src/entity/databases/model/Period.ts @@ -0,0 +1,13 @@ +export enum Period { + DAY = "DAY", + WEEK = "WEEK", + MONTH = "MONTH", + THREE_MONTH = "3_MONTH", + SIX_MONTH = "6_MONTH", + YEAR = "YEAR", + TWO_YEARS = "2_YEARS", + THREE_YEARS = "3_YEARS", + FOUR_YEARS = "4_YEARS", + FIVE_YEARS = "5_YEARS", + FOREVER = "FOREVER", +} \ No newline at end of file diff --git a/frontend/src/entity/databases/model/postgresql/PostgresqlDatabase.ts b/frontend/src/entity/databases/model/postgresql/PostgresqlDatabase.ts new file mode 100644 index 0000000..67a8ce3 --- /dev/null +++ b/frontend/src/entity/databases/model/postgresql/PostgresqlDatabase.ts @@ -0,0 +1,16 @@ +import type { PostgresqlVersion } from './PostgresqlVersion'; + +export interface PostgresqlDatabase { + id: string; + version: PostgresqlVersion; + + // connection data + host: string; + port: number; + username: string; + password: string; + database?: string; + isHttps: boolean; + + cpuCount: number; +} diff --git a/frontend/src/entity/databases/model/postgresql/PostgresqlVersion.ts b/frontend/src/entity/databases/model/postgresql/PostgresqlVersion.ts new file mode 100644 index 0000000..bcaecb3 --- /dev/null +++ b/frontend/src/entity/databases/model/postgresql/PostgresqlVersion.ts @@ -0,0 +1,7 @@ +export enum PostgresqlVersion { + PostgresqlVersion13 = "13", + PostgresqlVersion14 = "14", + PostgresqlVersion15 = "15", + PostgresqlVersion16 = "16", + PostgresqlVersion17 = "17", +} diff --git a/frontend/src/entity/intervals/index.ts b/frontend/src/entity/intervals/index.ts new file mode 100644 index 0000000..6022b34 --- /dev/null +++ b/frontend/src/entity/intervals/index.ts @@ -0,0 +1,2 @@ +export { type Interval } from './model/Interval'; +export { IntervalType } from './model/IntervalType'; diff --git a/frontend/src/entity/intervals/model/Interval.ts b/frontend/src/entity/intervals/model/Interval.ts new file mode 100644 index 0000000..6efc49f --- /dev/null +++ b/frontend/src/entity/intervals/model/Interval.ts @@ -0,0 +1,11 @@ +import type { IntervalType } from './IntervalType'; + +export interface Interval { + id: string; + interval: IntervalType; + timeOfDay: string; + // only for WEEKLY + weekday?: number; + // only for MONTHLY + dayOfMonth?: number; +} diff --git a/frontend/src/entity/intervals/model/IntervalType.ts b/frontend/src/entity/intervals/model/IntervalType.ts new file mode 100644 index 0000000..d429252 --- /dev/null +++ b/frontend/src/entity/intervals/model/IntervalType.ts @@ -0,0 +1,6 @@ +export enum IntervalType { + HOURLY = 'HOURLY', + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', +} diff --git a/frontend/src/entity/notifiers/api/notifierApi.ts b/frontend/src/entity/notifiers/api/notifierApi.ts new file mode 100644 index 0000000..84741fc --- /dev/null +++ b/frontend/src/entity/notifiers/api/notifierApi.ts @@ -0,0 +1,56 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { Notifier } from '../models/Notifier'; + +export const notifierApi = { + async saveNotifier(notifier: Notifier) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(notifier)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/notifiers`, + requestOptions, + ); + }, + + async getNotifier(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/notifiers/${id}`, + requestOptions, + ); + }, + + async getNotifiers() { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/notifiers`, + requestOptions, + ); + }, + + async deleteNotifier(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchDeleteJson( + `${getApplicationServer()}/api/v1/notifiers/${id}`, + requestOptions, + ); + }, + + async sendTestNotification(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/notifiers/${id}/test`, + requestOptions, + ); + }, + + async sendTestNotificationDirect(notifier: Notifier) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(notifier)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/notifiers/direct-test`, + requestOptions, + ); + }, +}; diff --git a/frontend/src/entity/notifiers/index.ts b/frontend/src/entity/notifiers/index.ts new file mode 100644 index 0000000..9cff01f --- /dev/null +++ b/frontend/src/entity/notifiers/index.ts @@ -0,0 +1,5 @@ +export { notifierApi } from './api/notifierApi'; +export type { Notifier } from './models/Notifier'; +export type { EmailNotifier } from './models/EmailNotifier'; +export type { TelegramNotifier } from './models/TelegramNotifier'; +export { NotifierType } from './models/NotifierType'; diff --git a/frontend/src/entity/notifiers/models/EmailNotifier.ts b/frontend/src/entity/notifiers/models/EmailNotifier.ts new file mode 100644 index 0000000..5d2f732 --- /dev/null +++ b/frontend/src/entity/notifiers/models/EmailNotifier.ts @@ -0,0 +1,7 @@ +export interface EmailNotifier { + targetEmail: string; + smtpHost: string; + smtpPort: number; + smtpUser: string; + smtpPassword: string; +} diff --git a/frontend/src/entity/notifiers/models/Notifier.ts b/frontend/src/entity/notifiers/models/Notifier.ts new file mode 100644 index 0000000..8a7f7a4 --- /dev/null +++ b/frontend/src/entity/notifiers/models/Notifier.ts @@ -0,0 +1,14 @@ +import type { EmailNotifier } from './EmailNotifier'; +import type { NotifierType } from './NotifierType'; +import type { TelegramNotifier } from './TelegramNotifier'; + +export interface Notifier { + id: string; + name: string; + notifierType: NotifierType; + lastSendError?: string; + + // specific notifier + telegramNotifier?: TelegramNotifier; + emailNotifier?: EmailNotifier; +} diff --git a/frontend/src/entity/notifiers/models/NotifierType.ts b/frontend/src/entity/notifiers/models/NotifierType.ts new file mode 100644 index 0000000..0ba0c3c --- /dev/null +++ b/frontend/src/entity/notifiers/models/NotifierType.ts @@ -0,0 +1,4 @@ +export enum NotifierType { + EMAIL = 'EMAIL', + TELEGRAM = 'TELEGRAM', +} diff --git a/frontend/src/entity/notifiers/models/TelegramNotifier.ts b/frontend/src/entity/notifiers/models/TelegramNotifier.ts new file mode 100644 index 0000000..0647bb8 --- /dev/null +++ b/frontend/src/entity/notifiers/models/TelegramNotifier.ts @@ -0,0 +1,4 @@ +export interface TelegramNotifier { + botToken: string; + targetChatId: string; +} diff --git a/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts b/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts new file mode 100644 index 0000000..d670168 --- /dev/null +++ b/frontend/src/entity/notifiers/models/getNotifierLogoFromType.ts @@ -0,0 +1,12 @@ +import { NotifierType } from './NotifierType'; + +export const getNotifierLogoFromType = (type: NotifierType) => { + switch (type) { + case NotifierType.EMAIL: + return '/icons/notifiers/email.svg'; + case NotifierType.TELEGRAM: + return '/icons/notifiers/telegram.svg'; + default: + return ''; + } +}; diff --git a/frontend/src/entity/notifiers/models/getNotifierNameFromType.ts b/frontend/src/entity/notifiers/models/getNotifierNameFromType.ts new file mode 100644 index 0000000..a4547d9 --- /dev/null +++ b/frontend/src/entity/notifiers/models/getNotifierNameFromType.ts @@ -0,0 +1,12 @@ +import { NotifierType } from './NotifierType'; + +export const getNotifierNameFromType = (type: NotifierType) => { + switch (type) { + case NotifierType.EMAIL: + return 'Email'; + case NotifierType.TELEGRAM: + return 'Telegram'; + default: + return ''; + } +}; diff --git a/frontend/src/entity/restores/api/restoreApi.ts b/frontend/src/entity/restores/api/restoreApi.ts new file mode 100644 index 0000000..e7269b4 --- /dev/null +++ b/frontend/src/entity/restores/api/restoreApi.ts @@ -0,0 +1,33 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { PostgresqlDatabase } from '../../databases'; +import type { Restore } from '../model/Restore'; + +export const restoreApi = { + async getRestores(backupId: string) { + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/restores/${backupId}`, + ); + }, + + async restoreBackup({ + backupId, + postgresql, + }: { + backupId: string; + postgresql: PostgresqlDatabase; + }) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody( + JSON.stringify({ + postgresqlDatabase: postgresql, + }), + ); + + return apiHelper.fetchPostJson<{ message: string }>( + `${getApplicationServer()}/api/v1/restores/${backupId}/restore`, + requestOptions, + ); + }, +}; diff --git a/frontend/src/entity/restores/index.ts b/frontend/src/entity/restores/index.ts new file mode 100644 index 0000000..f6c1839 --- /dev/null +++ b/frontend/src/entity/restores/index.ts @@ -0,0 +1,3 @@ +export { restoreApi } from './api/restoreApi'; +export { RestoreStatus } from './model/RestoreStatus'; +export type { Restore } from './model/Restore'; diff --git a/frontend/src/entity/restores/model/Restore.ts b/frontend/src/entity/restores/model/Restore.ts new file mode 100644 index 0000000..36a397b --- /dev/null +++ b/frontend/src/entity/restores/model/Restore.ts @@ -0,0 +1,14 @@ +import type { PostgresqlDatabase } from '../../databases'; +import { RestoreStatus } from './RestoreStatus'; + +export interface Restore { + id: string; + status: RestoreStatus; + + postgresql?: PostgresqlDatabase; + + failMessage?: string; + + restoreDurationMs: number; + createdAt: string; +} diff --git a/frontend/src/entity/restores/model/RestoreStatus.ts b/frontend/src/entity/restores/model/RestoreStatus.ts new file mode 100644 index 0000000..35dbc5a --- /dev/null +++ b/frontend/src/entity/restores/model/RestoreStatus.ts @@ -0,0 +1,5 @@ +export enum RestoreStatus { + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', +} diff --git a/frontend/src/entity/storages/api/storageApi.ts b/frontend/src/entity/storages/api/storageApi.ts new file mode 100644 index 0000000..ebce9bb --- /dev/null +++ b/frontend/src/entity/storages/api/storageApi.ts @@ -0,0 +1,56 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { Storage } from '../models/Storage'; + +export const storageApi = { + async saveStorage(storage: Storage) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(storage)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/storages`, + requestOptions, + ); + }, + + async getStorage(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/storages/${id}`, + requestOptions, + ); + }, + + async getStorages() { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/storages`, + requestOptions, + ); + }, + + async deleteStorage(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchDeleteJson( + `${getApplicationServer()}/api/v1/storages/${id}`, + requestOptions, + ); + }, + + async testStorageConnection(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/storages/${id}/test`, + requestOptions, + ); + }, + + async testStorageConnectionDirect(storage: Storage) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(storage)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/storages/direct-test`, + requestOptions, + ); + }, +}; diff --git a/frontend/src/entity/storages/index.ts b/frontend/src/entity/storages/index.ts new file mode 100644 index 0000000..15d7f0e --- /dev/null +++ b/frontend/src/entity/storages/index.ts @@ -0,0 +1,7 @@ +export { storageApi } from './api/storageApi'; +export { type Storage } from './models/Storage'; +export { StorageType } from './models/StorageType'; +export { type LocalStorage } from './models/LocalStorage'; +export { type S3Storage } from './models/S3Storage'; +export { getStorageLogoFromType } from './models/getStorageLogoFromType'; +export { getStorageNameFromType } from './models/getStorageNameFromType'; diff --git a/frontend/src/entity/storages/models/LocalStorage.ts b/frontend/src/entity/storages/models/LocalStorage.ts new file mode 100644 index 0000000..9d250d4 --- /dev/null +++ b/frontend/src/entity/storages/models/LocalStorage.ts @@ -0,0 +1 @@ +export type LocalStorage = object diff --git a/frontend/src/entity/storages/models/S3Storage.ts b/frontend/src/entity/storages/models/S3Storage.ts new file mode 100644 index 0000000..3216ee2 --- /dev/null +++ b/frontend/src/entity/storages/models/S3Storage.ts @@ -0,0 +1,7 @@ +export interface S3Storage { + s3Bucket: string; + s3Region: string; + s3AccessKey: string; + s3SecretKey: string; + s3Endpoint?: string; +} diff --git a/frontend/src/entity/storages/models/Storage.ts b/frontend/src/entity/storages/models/Storage.ts new file mode 100644 index 0000000..6978125 --- /dev/null +++ b/frontend/src/entity/storages/models/Storage.ts @@ -0,0 +1,14 @@ +import type { LocalStorage } from './LocalStorage'; +import type { S3Storage } from './S3Storage'; +import type { StorageType } from './StorageType'; + +export interface Storage { + id: string; + type: StorageType; + name: string; + lastSaveError?: string; + + // specific storage types + localStorage?: LocalStorage; + s3Storage?: S3Storage; +} diff --git a/frontend/src/entity/storages/models/StorageType.ts b/frontend/src/entity/storages/models/StorageType.ts new file mode 100644 index 0000000..875a533 --- /dev/null +++ b/frontend/src/entity/storages/models/StorageType.ts @@ -0,0 +1,4 @@ +export enum StorageType { + LOCAL = 'LOCAL', + S3 = 'S3', +} diff --git a/frontend/src/entity/storages/models/getStorageLogoFromType.ts b/frontend/src/entity/storages/models/getStorageLogoFromType.ts new file mode 100644 index 0000000..bc11a46 --- /dev/null +++ b/frontend/src/entity/storages/models/getStorageLogoFromType.ts @@ -0,0 +1,12 @@ +import { StorageType } from './StorageType'; + +export const getStorageLogoFromType = (type: StorageType) => { + switch (type) { + case StorageType.LOCAL: + return '/icons/storages/local.svg'; + case StorageType.S3: + return '/icons/storages/s3.svg'; + default: + return ''; + } +}; diff --git a/frontend/src/entity/storages/models/getStorageNameFromType.ts b/frontend/src/entity/storages/models/getStorageNameFromType.ts new file mode 100644 index 0000000..2eb42d3 --- /dev/null +++ b/frontend/src/entity/storages/models/getStorageNameFromType.ts @@ -0,0 +1,12 @@ +import { StorageType } from './StorageType'; + +export const getStorageNameFromType = (type: StorageType) => { + switch (type) { + case StorageType.LOCAL: + return 'local storage'; + case StorageType.S3: + return 'S3'; + default: + return ''; + } +}; diff --git a/frontend/src/entity/users/api/userApi.ts b/frontend/src/entity/users/api/userApi.ts new file mode 100644 index 0000000..5cbd25a --- /dev/null +++ b/frontend/src/entity/users/api/userApi.ts @@ -0,0 +1,76 @@ +import { getApplicationServer } from '../../../constants'; +import RequestOptions from '../../../shared/api/RequestOptions'; +import { accessTokenHelper } from '../../../shared/api/accessTokenHelper'; +import { apiHelper } from '../../../shared/api/apiHelper'; +import type { SignInRequest } from '../model/SignInRequest'; +import type { SignInResponse } from '../model/SignInResponse'; +import type { SignUpRequest } from '../model/SignUpRequest'; + +const listeners: (() => void)[] = []; + +const saveAuthorizedData = (accessToken: string, userId: string) => { + accessTokenHelper.saveAccessToken(accessToken); + accessTokenHelper.saveUserId(userId); +}; + +const notifyAuthListeners = () => { + for (const listener of listeners) { + listener(); + } +}; + +export const userApi = { + async signUp(signUpRequest: SignUpRequest) { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(signUpRequest)); + return apiHelper.fetchPostRaw(`${getApplicationServer()}/api/v1/users/signup`, requestOptions); + }, + + async signIn(signInRequest: SignInRequest): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(signInRequest)); + + return apiHelper + .fetchPostJson(`${getApplicationServer()}/api/v1/users/signin`, requestOptions) + .then((response: unknown): SignInResponse => { + const typedResponse = response as SignInResponse; + saveAuthorizedData(typedResponse.token, typedResponse.userId); + notifyAuthListeners(); + return typedResponse; + }); + }, + + async isAnyUserExists(): Promise { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper + .fetchGetJson(`${getApplicationServer()}/api/v1/users/is-any-user-exist`, requestOptions) + .then((response: unknown) => { + const typedResponse = response as { isExist: boolean }; + return typedResponse.isExist; + }); + }, + + isAuthorized: (): boolean => !!accessTokenHelper.getAccessToken(), + + logout: () => { + accessTokenHelper.cleanAccessToken(); + }, + + // listeners + + addAuthListener: (listener: () => void) => { + listeners.push(listener); + }, + + removeAuthListener: (listener: () => void) => { + listeners.splice(listeners.indexOf(listener), 1); + }, + + notifyAuthListeners: (): void => { + for (const listener of listeners) { + listener(); + } + }, + + saveAuthorizedData, +}; diff --git a/frontend/src/entity/users/index.ts b/frontend/src/entity/users/index.ts new file mode 100644 index 0000000..f753ecc --- /dev/null +++ b/frontend/src/entity/users/index.ts @@ -0,0 +1 @@ +export { userApi } from './api/userApi'; diff --git a/frontend/src/entity/users/model/SignInRequest.ts b/frontend/src/entity/users/model/SignInRequest.ts new file mode 100644 index 0000000..7f0ecfb --- /dev/null +++ b/frontend/src/entity/users/model/SignInRequest.ts @@ -0,0 +1,4 @@ +export interface SignInRequest { + email: string; + password: string; +} diff --git a/frontend/src/entity/users/model/SignInResponse.ts b/frontend/src/entity/users/model/SignInResponse.ts new file mode 100644 index 0000000..bbbf3ce --- /dev/null +++ b/frontend/src/entity/users/model/SignInResponse.ts @@ -0,0 +1,4 @@ +export interface SignInResponse { + userId: string; + token: string; +} diff --git a/frontend/src/entity/users/model/SignUpRequest.ts b/frontend/src/entity/users/model/SignUpRequest.ts new file mode 100644 index 0000000..0e97ca6 --- /dev/null +++ b/frontend/src/entity/users/model/SignUpRequest.ts @@ -0,0 +1,4 @@ +export interface SignUpRequest { + email: string; + password: string; +} diff --git a/frontend/src/features/backups/index.ts b/frontend/src/features/backups/index.ts new file mode 100644 index 0000000..d2e5630 --- /dev/null +++ b/frontend/src/features/backups/index.ts @@ -0,0 +1 @@ +export { BackupsComponent } from './ui/BackupsComponent'; diff --git a/frontend/src/features/backups/ui/BackupsComponent.tsx b/frontend/src/features/backups/ui/BackupsComponent.tsx new file mode 100644 index 0000000..d268977 --- /dev/null +++ b/frontend/src/features/backups/ui/BackupsComponent.tsx @@ -0,0 +1,327 @@ +import { + CheckCircleOutlined, + CloudUploadOutlined, + DeleteOutlined, + ExclamationCircleOutlined, + InfoCircleOutlined, + SyncOutlined, +} from '@ant-design/icons'; +import { Button, Modal, Table, Tooltip } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { useEffect, useRef, useState } from 'react'; + +import { type Backup, BackupStatus, backupsApi } from '../../../entity/backups'; +import type { Database } from '../../../entity/databases'; +import { getUserTimeFormat } from '../../../shared/time'; +import { ConfirmationComponent } from '../../../shared/ui'; +import { RestoresComponent } from '../../restores'; + +interface Props { + database: Database; +} + +export const BackupsComponent = ({ database }: Props) => { + const [isLoading, setIsLoading] = useState(false); + const [backups, setBackups] = useState([]); + + const [isMakeBackupRequestLoading, setIsMakeBackupRequestLoading] = useState(false); + + const [showingBackupError, setShowingBackupError] = useState(); + + const [deleteConfimationId, setDeleteConfimationId] = useState(); + const [deletingBackupId, setDeletingBackupId] = useState(); + + const [showingRestoresBackupId, setShowingRestoresBackupId] = useState(); + + const isReloadInProgress = useRef(false); + + const loadBackups = async () => { + if (isReloadInProgress.current) { + return; + } + + isReloadInProgress.current = true; + + try { + const backups = await backupsApi.getBackups(database.id); + setBackups(backups); + } catch (e) { + alert((e as Error).message); + } + + isReloadInProgress.current = false; + }; + + const makeBackup = async () => { + setIsMakeBackupRequestLoading(true); + + try { + await backupsApi.makeBackup(database.id); + await loadBackups(); + } catch (e) { + alert((e as Error).message); + } + + setIsMakeBackupRequestLoading(false); + }; + + const deleteBackup = async () => { + if (!deleteConfimationId) { + return; + } + + setDeleteConfimationId(undefined); + setDeletingBackupId(deleteConfimationId); + + try { + await backupsApi.deleteBackup(deleteConfimationId); + await loadBackups(); + } catch (e) { + alert((e as Error).message); + } + + setDeletingBackupId(undefined); + setDeleteConfimationId(undefined); + }; + + useEffect(() => { + setIsLoading(true); + loadBackups().then(() => setIsLoading(false)); + + const interval = setInterval(() => { + loadBackups(); + }, 1_000); + + return () => clearInterval(interval); + }, [database]); + + const columns: ColumnsType = [ + { + title: 'Created at', + dataIndex: 'createdAt', + key: 'createdAt', + render: (createdAt: string) => ( +
+ {dayjs.utc(createdAt).local().format(getUserTimeFormat().format)}
+ ({dayjs.utc(createdAt).local().fromNow()}) +
+ ), + sorter: (a, b) => dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(), + defaultSortOrder: 'descend', + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: BackupStatus, record: Backup) => { + if (status === BackupStatus.FAILED) { + return ( + +
setShowingBackupError(record)} + > + + +
Failed
+
+
+ ); + } + + if (status === BackupStatus.COMPLETED) { + return ( +
+ +
Successful
+
+ ); + } + + if (status === BackupStatus.DELETED) { + return ( +
+ +
Deleted
+
+ ); + } + + if (status === BackupStatus.IN_PROGRESS) { + return ( +
+ + In progress +
+ ); + } + + return {status}; + }, + filters: [ + { + value: BackupStatus.IN_PROGRESS, + text: 'In progress', + }, + { + value: BackupStatus.FAILED, + text: 'Failed', + }, + { + value: BackupStatus.COMPLETED, + text: 'Successful', + }, + { + value: BackupStatus.DELETED, + text: 'Deleted', + }, + ], + onFilter: (value, record) => record.status === value, + }, + { + title: ( +
+ Size + + + +
+ ), + dataIndex: 'backupSizeMb', + key: 'backupSizeMb', + render: (sizeMb: number) => { + if (sizeMb >= 1024) { + const sizeGb = sizeMb / 1024; + return `${Number(sizeGb.toFixed(2)).toLocaleString()} GB`; + } + return `${Number(sizeMb?.toFixed(2)).toLocaleString()} MB`; + }, + }, + { + title: 'Duration', + dataIndex: 'backupDurationMs', + key: 'backupDurationMs', + render: (durationMs: number) => { + const minutes = Math.floor(durationMs / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + const milliseconds = durationMs % 1000; + return `${minutes}m ${seconds}s ${milliseconds}ms`; + }, + }, + { + title: 'Actions', + dataIndex: '', + key: '', + render: (_, record: Backup) => { + return ( +
+ {record.status === BackupStatus.COMPLETED && ( +
+ {deletingBackupId === record.id ? ( + + ) : ( + <> + + { + if (deletingBackupId) return; + setDeleteConfimationId(record.id); + }} + style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }} + /> + + + + { + setShowingRestoresBackupId(record.id); + }} + style={{ + color: '#0d6efd', + }} + /> + + + )} +
+ )} +
+ ); + }, + }, + ]; + + return ( +
+

Backups

+ +
+ +
+ +
+ +
+ + + + {deleteConfimationId && ( + setDeleteConfimationId(undefined)} + description="Are you sure you want to delete this backup?" + actionButtonColor="red" + actionText="Delete" + /> + )} + + {showingRestoresBackupId && ( + setShowingRestoresBackupId(undefined)} + title="Restore from backup" + footer={null} + > + b.id === showingRestoresBackupId) as Backup} + /> + + )} + + {showingBackupError && ( + setShowingBackupError(undefined)} + footer={null} + > +
{showingBackupError.failMessage}
+
+ )} + + ); +}; diff --git a/frontend/src/features/databases/index.ts b/frontend/src/features/databases/index.ts new file mode 100644 index 0000000..55db187 --- /dev/null +++ b/frontend/src/features/databases/index.ts @@ -0,0 +1 @@ +export { DatabasesComponent } from './ui/DatabasesComponent'; diff --git a/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx new file mode 100644 index 0000000..1448264 --- /dev/null +++ b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; + +import { backupsApi } from '../../../entity/backups'; +import { + type Database, + DatabaseType, + Period, + type PostgresqlDatabase, + databaseApi, +} from '../../../entity/databases'; +import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent'; +import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent'; +import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent'; +import { EditDatabaseStorageComponent } from './edit/EditDatabaseStorageComponent'; + +interface Props { + onCreated: () => void; + + onClose: () => void; +} + +export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => { + const [isCreating, setIsCreating] = useState(false); + const [database, setDatabase] = useState({ + id: undefined as unknown as string, + name: '', + storePeriod: Period.WEEK, + + postgresql: { + cpuCount: 1, + } as unknown as PostgresqlDatabase, + + type: DatabaseType.POSTGRES, + + storage: {} as unknown as Storage, + + notifiers: [], + sendNotificationsOn: [], + } as Database); + + const [step, setStep] = useState<'base-info' | 'db-settings' | 'storages' | 'notifiers'>( + 'base-info', + ); + + const createDatabase = async (database: Database) => { + setIsCreating(true); + + try { + const createdDatabase = await databaseApi.createDatabase(database); + setDatabase({ ...createdDatabase }); + + await backupsApi.makeBackup(createdDatabase.id); + onCreated(); + onClose(); + } catch (error) { + alert(error); + } + + setIsCreating(false); + }; + + if (step === 'base-info') { + return ( +
+ onClose()} + onSaved={(database) => { + setDatabase({ ...database }); + setStep('db-settings'); + }} + /> +
+ ); + } + + if (step === 'db-settings') { + return ( + onClose()} + isShowBackButton + onBack={() => setStep('base-info')} + saveButtonText="Continue" + isSaveToApi={false} + onSaved={(database) => { + setDatabase({ ...database }); + setStep('storages'); + }} + /> + ); + } + + if (step === 'storages') { + return ( + onClose()} + isShowBackButton + onBack={() => setStep('db-settings')} + isShowSaveOnlyForUnsaved={false} + saveButtonText="Continue" + isSaveToApi={false} + onSaved={(database) => { + setDatabase({ ...database }); + setStep('notifiers'); + }} + /> + ); + } + + if (step === 'notifiers') { + return ( + onClose()} + isShowBackButton + onBack={() => setStep('storages')} + isShowSaveOnlyForUnsaved={false} + saveButtonText="Complete" + isSaveToApi={false} + onSaved={(database) => { + if (isCreating) return; + + setDatabase({ ...database }); + createDatabase(database); + }} + /> + ); + } +}; diff --git a/frontend/src/features/databases/ui/DatabaseCardComponent.tsx b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx new file mode 100644 index 0000000..7ea4244 --- /dev/null +++ b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx @@ -0,0 +1,68 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; + +import { type Database, DatabaseType } from '../../../entity/databases'; +import { getStorageLogoFromType } from '../../../entity/storages'; +import { getUserTimeFormat } from '../../../shared/time'; + +interface Props { + database: Database; + selectedDatabaseId?: string; + setSelectedDatabaseId: (databaseId: string) => void; +} + +export const DatabaseCardComponent = ({ + database, + selectedDatabaseId, + setSelectedDatabaseId, +}: Props) => { + let databaseIcon = ''; + let databaseType = ''; + + if (database.type === DatabaseType.POSTGRES) { + databaseIcon = '/icons/databases/postgresql.svg'; + databaseType = 'PostgreSQL'; + } + + return ( +
setSelectedDatabaseId(database.id)} + > +
{database.name}
+ +
+
Database type: {databaseType}
+ + databaseIcon +
+ +
+
Store to: {database.storage?.name}
+ + databaseIcon +
+ + {database.lastBackupTime && ( +
+ Last backup +
+ {dayjs(database.lastBackupTime).format(getUserTimeFormat().format)} +
+ {dayjs(database.lastBackupTime).fromNow()} +
+ )} + + {database.lastBackupErrorMessage && ( +
+ + Has backup error +
+ )} +
+ ); +}; diff --git a/frontend/src/features/databases/ui/DatabaseComponent.tsx b/frontend/src/features/databases/ui/DatabaseComponent.tsx new file mode 100644 index 0000000..32cfc28 --- /dev/null +++ b/frontend/src/features/databases/ui/DatabaseComponent.tsx @@ -0,0 +1,411 @@ +import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import { Button, Input, Spin } from 'antd'; +import { useState } from 'react'; +import { useEffect } from 'react'; + +import { type Database, databaseApi } from '../../../entity/databases'; +import { ToastHelper } from '../../../shared/toast'; +import { ConfirmationComponent } from '../../../shared/ui'; +import { BackupsComponent } from '../../backups'; +import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent'; +import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent'; +import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent'; +import { EditDatabaseStorageComponent } from './edit/EditDatabaseStorageComponent'; +import { ShowDatabaseBaseInfoComponent } from './show/ShowDatabaseBaseInfoComponent'; +import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent'; +import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDataComponent'; +import { ShowDatabaseStorageComponent } from './show/ShowDatabaseStorageComponent'; + +interface Props { + contentHeight: number; + databaseId: string; + onDatabaseChanged: (database: Database) => void; + onDatabaseDeleted: () => void; +} + +export const DatabaseComponent = ({ + contentHeight, + databaseId, + onDatabaseChanged, + onDatabaseDeleted, +}: Props) => { + const [database, setDatabase] = useState(); + + const [isEditName, setIsEditName] = useState(false); + const [isEditBaseSettings, setIsEditBaseSettings] = useState(false); + const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] = + useState(false); + const [isEditStorageSettings, setIsEditStorageSettings] = useState(false); + const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false); + + const [editDatabase, setEditDatabase] = useState(); + const [isNameUnsaved, setIsNameUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const [isTestingConnection, setIsTestingConnection] = useState(false); + + const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + const testConnection = () => { + if (!database) return; + + setIsTestingConnection(true); + databaseApi + .testDatabaseConnection(database.id) + .then(() => { + ToastHelper.showToast({ + title: 'Connection test successful!', + description: 'Database connection tested successfully', + }); + + if (database.lastBackupErrorMessage) { + setDatabase({ ...database, lastBackupErrorMessage: undefined }); + onDatabaseChanged(database); + } + }) + .catch((e: Error) => { + alert(e.message); + }) + .finally(() => { + setIsTestingConnection(false); + }); + }; + + const remove = () => { + if (!database) return; + + setIsRemoving(true); + databaseApi + .deleteDatabase(database.id) + .then(() => { + onDatabaseDeleted(); + }) + .catch((e: Error) => { + alert(e.message); + }) + .finally(() => { + setIsRemoving(false); + }); + }; + + const startEdit = (type: 'name' | 'settings' | 'database' | 'storage' | 'notifiers') => { + setEditDatabase(JSON.parse(JSON.stringify(database))); + setIsEditName(type === 'name'); + setIsEditBaseSettings(type === 'settings'); + setIsEditDatabaseSpecificDataSettings(type === 'database'); + setIsEditStorageSettings(type === 'storage'); + setIsEditNotifiersSettings(type === 'notifiers'); + setIsNameUnsaved(false); + }; + + const saveName = () => { + if (!editDatabase) return; + + setIsSaving(true); + databaseApi + .updateDatabase(editDatabase) + .then(() => { + setDatabase(editDatabase); + setIsSaving(false); + setIsNameUnsaved(false); + setIsEditName(false); + onDatabaseChanged(editDatabase); + }) + .catch((e: Error) => { + alert(e.message); + setIsSaving(false); + }); + }; + + const loadSettings = () => { + setDatabase(undefined); + setEditDatabase(undefined); + databaseApi.getDatabase(databaseId).then(setDatabase); + }; + + useEffect(() => { + loadSettings(); + }, [databaseId]); + + return ( +
+
+ {!database ? ( +
+ +
+ ) : ( +
+ {!isEditName ? ( +
+ {database.name} +
startEdit('name')}> + +
+
+ ) : ( +
+
+ { + if (!editDatabase) return; + + setEditDatabase({ ...editDatabase, name: e.target.value }); + setIsNameUnsaved(true); + }} + placeholder="Enter name..." + size="large" + /> + +
+ +
+
+ + {isNameUnsaved && ( + + )} +
+ )} + + {database.lastBackupErrorMessage && ( +
+
+ + Last backup error +
+ +
+ The error: +
+ {database.lastBackupErrorMessage} +
+ +
+ To clean this error (choose any): +
    +
  • - test connection via button below (even if you updated settings);
  • +
  • - wait until the next backup is done without errors;
  • +
+
+
+ )} + +
+
+
+
Backup settings
+ + {!isEditBaseSettings ? ( +
startEdit('settings')} + > + +
+ ) : ( +
+ )} +
+ +
+ {isEditBaseSettings ? ( + { + setIsEditBaseSettings(false); + loadSettings(); + }} + isSaveToApi={true} + onSaved={onDatabaseChanged} + /> + ) : ( + + )} +
+
+ +
+
+
Database settings
+ + {!isEditDatabaseSpecificDataSettings ? ( +
startEdit('database')} + > + +
+ ) : ( +
+ )} +
+ +
+ {isEditDatabaseSpecificDataSettings ? ( + {}} + onCancel={() => { + setIsEditDatabaseSpecificDataSettings(false); + loadSettings(); + }} + isSaveToApi={true} + onSaved={onDatabaseChanged} + /> + ) : ( + + )} +
+
+
+ +
+
+
+
Storage settings
+ + {!isEditStorageSettings ? ( +
startEdit('storage')} + > + +
+ ) : ( +
+ )} +
+ +
+
+ {isEditStorageSettings ? ( + {}} + onCancel={() => { + setIsEditStorageSettings(false); + loadSettings(); + }} + isSaveToApi={true} + onSaved={onDatabaseChanged} + /> + ) : ( + + )} +
+
+
+ +
+
+
Notifiers settings
+ + {!isEditNotifiersSettings ? ( +
startEdit('notifiers')} + > + +
+ ) : ( +
+ )} +
+ +
+ {isEditNotifiersSettings ? ( + {}} + onCancel={() => { + setIsEditNotifiersSettings(false); + loadSettings(); + }} + isSaveToApi={true} + saveButtonText="Save" + onSaved={onDatabaseChanged} + /> + ) : ( + + )} +
+
+
+ + {!isEditDatabaseSpecificDataSettings && ( +
+ + + +
+ )} +
+ )} + + {isShowRemoveConfirm && ( + setIsShowRemoveConfirm(false)} + description="Are you sure you want to remove this database? This action cannot be undone." + actionText="Remove" + actionButtonColor="red" + /> + )} +
+ +
+ {database && } +
+
+ ); +}; diff --git a/frontend/src/features/databases/ui/DatabasesComponent.tsx b/frontend/src/features/databases/ui/DatabasesComponent.tsx new file mode 100644 index 0000000..e5c4889 --- /dev/null +++ b/frontend/src/features/databases/ui/DatabasesComponent.tsx @@ -0,0 +1,107 @@ +import { Button, Modal, Spin } from 'antd'; +import { useEffect, useState } from 'react'; + +import { databaseApi } from '../../../entity/databases'; +import type { Database } from '../../../entity/databases'; +import { CreateDatabaseComponent } from './CreateDatabaseComponent'; +import { DatabaseCardComponent } from './DatabaseCardComponent'; +import { DatabaseComponent } from './DatabaseComponent'; + +interface Props { + contentHeight: number; +} +export const DatabasesComponent = ({ contentHeight }: Props) => { + const [isLoading, setIsLoading] = useState(true); + const [databases, setDatabases] = useState([]); + + const [isShowAddDatabase, setIsShowAddDatabase] = useState(false); + const [selectedDatabaseId, setSelectedDatabaseId] = useState(undefined); + + const loadDatabases = () => { + setIsLoading(true); + + databaseApi + .getDatabases() + .then((databases) => { + setDatabases(databases); + if (!selectedDatabaseId) { + setSelectedDatabaseId(databases[0]?.id); + } + }) + .catch((e) => alert(e.message)) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + loadDatabases(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+ {databases.map((database) => ( + + ))} + + + +
+ Database - is a thing we are backing up +
+
+ + {selectedDatabaseId && ( + { + loadDatabases(); + }} + onDatabaseDeleted={() => { + loadDatabases(); + setSelectedDatabaseId( + databases.filter((database) => database.id !== selectedDatabaseId)[0]?.id, + ); + }} + /> + )} +
+ + {isShowAddDatabase && ( + } + open={isShowAddDatabase} + onCancel={() => setIsShowAddDatabase(false)} + width={420} + > +
+ + { + loadDatabases(); + setIsShowAddDatabase(false); + }} + onClose={() => setIsShowAddDatabase(false)} + /> + + )} + + ); +}; diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx new file mode 100644 index 0000000..23092c4 --- /dev/null +++ b/frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx @@ -0,0 +1,264 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Button, Input, InputNumber, Select, TimePicker, Tooltip } from 'antd'; +import dayjs, { Dayjs } from 'dayjs'; +import { useEffect, useMemo, useState } from 'react'; + +import { type Database, databaseApi } from '../../../../entity/databases'; +import { Period } from '../../../../entity/databases/model/Period'; +import { type Interval, IntervalType } from '../../../../entity/intervals'; + +interface Props { + database: Database; + + isShowName?: boolean; + + isShowCancelButton?: boolean; + onCancel: () => void; + + saveButtonText?: string; + isSaveToApi: boolean; + onSaved: (database: Database) => void; +} + +const weekdayOptions = [ + { value: 1, label: 'Mon' }, + { value: 2, label: 'Tue' }, + { value: 3, label: 'Wed' }, + { value: 4, label: 'Thu' }, + { value: 5, label: 'Fri' }, + { value: 6, label: 'Sat' }, + { value: 7, label: 'Sun' }, +]; + +// Function to detect if user prefers 12-hour format based on their locale +const getUserTimeFormat = () => { + const locale = navigator.language || 'en-US'; + const testDate = new Date(2023, 0, 1, 13, 0, 0); // 1 PM + const timeString = testDate.toLocaleTimeString(locale, { hour: 'numeric' }); + return timeString.includes('PM') || timeString.includes('AM'); +}; + +export const EditDatabaseBaseInfoComponent = ({ + database, + + isShowName, + + isShowCancelButton, + onCancel, + + saveButtonText, + isSaveToApi, + onSaved, +}: Props) => { + const [editingDatabase, setEditingDatabase] = useState(); + const [isUnsaved, setIsUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Detect user's preferred time format (12-hour vs 24-hour) + const timeFormat = useMemo(() => { + const is12Hour = getUserTimeFormat(); + return { + use12Hours: is12Hour, + format: is12Hour ? 'h:mm A' : 'HH:mm', + }; + }, []); + + const updateDatabase = (patch: Partial) => { + if (!editingDatabase) return; + setEditingDatabase({ ...editingDatabase, ...patch }); + setIsUnsaved(true); + }; + + const saveDatabase = async () => { + if (!editingDatabase) return; + + if (isSaveToApi) { + setIsSaving(true); + + try { + await databaseApi.updateDatabase(editingDatabase); + setIsUnsaved(false); + } catch (e) { + alert((e as Error).message); + } + + setIsSaving(false); + } + + onSaved(editingDatabase); + }; + + const saveInterval = (patch: Partial) => { + if (!editingDatabase) return; + const current = editingDatabase.backupInterval ?? ({} as Interval); + updateDatabase({ backupInterval: { ...current, ...patch } }); + }; + + useEffect(() => { + setIsSaving(false); + setIsUnsaved(false); + + setEditingDatabase({ ...database }); + }, [database]); + + if (!editingDatabase) return null; + + const { backupInterval } = editingDatabase; + + const localTime: Dayjs | undefined = backupInterval?.timeOfDay + ? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local() /* cast to user tz */ + : undefined; + + let isAllFieldsFilled = true; + + if (!editingDatabase.name) isAllFieldsFilled = false; + if (!editingDatabase.storePeriod) isAllFieldsFilled = false; + + if (!editingDatabase.backupInterval?.interval) isAllFieldsFilled = false; + if (editingDatabase.backupInterval?.interval === IntervalType.WEEKLY) { + if (!editingDatabase.backupInterval?.weekday) isAllFieldsFilled = false; + } + if (editingDatabase.backupInterval?.interval === IntervalType.MONTHLY) { + if (!editingDatabase.backupInterval.dayOfMonth) isAllFieldsFilled = false; + } + + return ( +
+ {isShowName && ( +
+
Name
+ { + updateDatabase({ name: e.target.value }); + }} + size="small" + placeholder="My favourite DB" + className="max-w-[200px] grow" + /> +
+ )} + +
+
Backup interval
+ { + saveInterval({ weekday: v }); + }} + size="small" + className="max-w-[200px] grow" + options={weekdayOptions} + placeholder="Select backup weekday" + /> +
+ )} + + {backupInterval?.interval === IntervalType.MONTHLY && ( +
+
Backup day of month
+ { + saveInterval({ dayOfMonth: v ?? 1 }); + }} + size="small" + className="max-w-[200px] grow" + placeholder="Select backup day of month" + /> +
+ )} + + {backupInterval?.interval !== IntervalType.HOURLY && ( +
+
Backup time of day
+ { + if (!t) return; + // convert local picker value → UTC "HH:mm" + const utcString = t.utc().format('HH:mm'); + saveInterval({ timeOfDay: utcString }); + }} + allowClear={false} + size="small" + className="max-w-[200px] grow" + /> +
+ )} + +
+
Store period
+ { + setEditingDatabase({ + ...editingDatabase, + sendNotificationsOn, + } as unknown as Database); + + setIsUnsaved(true); + }} + size="small" + className="max-w-[200px] grow" + options={[ + { + label: 'Backup failed', + value: BackupNotificationType.BACKUP_FAILED, + }, + { + label: 'Backup success', + value: BackupNotificationType.BACKUP_SUCCESS, + }, + ]} + /> +
+ +
+
Notifiers
+ + { + setEditingDatabase({ + ...editingDatabase, + type: v, + postgresql: {} as unknown as PostgresqlDatabase, + } as Database); + + setIsConnectionTested(false); + }} + disabled={!!editingDatabase.id} + size="small" + className="max-w-[200px] grow" + options={[{ label: 'PostgreSQL', value: DatabaseType.POSTGRES }]} + placeholder="Select database type" + /> +
+ + {editingDatabase.type === DatabaseType.POSTGRES && ( + <> +
+
PG version
+ + { + if (!editingDatabase.postgresql) return; + + setEditingDatabase({ + ...editingDatabase, + postgresql: { + ...editingDatabase.postgresql, + host: e.target.value.trim().replace('https://', '').replace('http://', ''), + }, + }); + }} + size="small" + className="max-w-[200px] grow" + placeholder="Enter PG host" + /> +
+ +
+
Port
+ { + if (!editingDatabase.postgresql || e === null) return; + + setEditingDatabase({ + ...editingDatabase, + postgresql: { ...editingDatabase.postgresql, port: e }, + }); + setIsConnectionTested(false); + }} + size="small" + className="max-w-[200px] grow" + placeholder="Enter PG port" + /> +
+ +
+
Username
+ { + if (!editingDatabase.postgresql) return; + + setEditingDatabase({ + ...editingDatabase, + postgresql: { ...editingDatabase.postgresql, username: e.target.value.trim() }, + }); + }} + size="small" + className="max-w-[200px] grow" + placeholder="Enter PG username" + /> +
+ +
+
Password
+ { + if (!editingDatabase.postgresql) return; + + setEditingDatabase({ + ...editingDatabase, + postgresql: { ...editingDatabase.postgresql, password: e.target.value }, + }); + setIsConnectionTested(false); + }} + size="small" + className="max-w-[200px] grow" + placeholder="Enter PG password" + /> +
+ + {isShowDbName && ( +
+
DB name
+ { + if (!editingDatabase.postgresql) return; + + setEditingDatabase({ + ...editingDatabase, + postgresql: { ...editingDatabase.postgresql, database: e.target.value.trim() }, + }); + setIsConnectionTested(false); + }} + size="small" + className="max-w-[200px] grow" + placeholder="Enter PG database name (optional)" + /> +
+ )} + +
+
Use HTTPS
+ { + if (!editingDatabase.postgresql) return; + + setEditingDatabase({ + ...editingDatabase, + postgresql: { ...editingDatabase.postgresql, isHttps: checked }, + }); + setIsConnectionTested(false); + }} + size="small" + /> +
+ +
+
CPU count
+ { + if (!editingDatabase.postgresql || e === null) return; + + setEditingDatabase({ + ...editingDatabase, + postgresql: { ...editingDatabase.postgresql, cpuCount: e }, + }); + setIsConnectionTested(false); + }} + size="small" + className="max-w-[200px] grow" + placeholder="Enter PG CPU count" + min={1} + step={1} + /> + + + +
+ + )} + +
+ {isShowCancelButton && ( + + )} + + {isShowBackButton && ( + + )} + + {!isConnectionTested && ( + + )} + + {isConnectionTested && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseStorageComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseStorageComponent.tsx new file mode 100644 index 0000000..f268da5 --- /dev/null +++ b/frontend/src/features/databases/ui/edit/EditDatabaseStorageComponent.tsx @@ -0,0 +1,199 @@ +import { Button, Modal, Select, Spin } from 'antd'; +import { useEffect, useState } from 'react'; + +import { type Database, databaseApi } from '../../../../entity/databases'; +import { type Storage, storageApi } from '../../../../entity/storages'; +import { ConfirmationComponent } from '../../../../shared/ui'; +import { EditStorageComponent } from '../../../storages/ui/edit/EditStorageComponent'; + +interface Props { + database: Database; + + isShowCancelButton?: boolean; + onCancel: () => void; + + isShowBackButton: boolean; + onBack: () => void; + + isShowSaveOnlyForUnsaved: boolean; + saveButtonText?: string; + isSaveToApi: boolean; + onSaved: (database: Database) => void; +} + +export const EditDatabaseStorageComponent = ({ + database, + + isShowCancelButton, + onCancel, + + isShowBackButton, + onBack, + + isShowSaveOnlyForUnsaved, + saveButtonText, + isSaveToApi, + onSaved, +}: Props) => { + const [editingDatabase, setEditingDatabase] = useState(); + const [isUnsaved, setIsUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const [storages, setStorages] = useState([]); + const [isStoragesLoading, setIsStoragesLoading] = useState(false); + const [isShowCreateStorage, setShowCreateStorage] = useState(false); + + const [isShowWarn, setIsShowWarn] = useState(false); + + const saveDatabase = async () => { + if (!editingDatabase) return; + + if (isSaveToApi) { + setIsSaving(true); + + try { + await databaseApi.updateDatabase(editingDatabase); + setIsUnsaved(false); + } catch (e) { + alert((e as Error).message); + } + + setIsSaving(false); + } + + onSaved(editingDatabase); + }; + + const loadStorages = async () => { + setIsStoragesLoading(true); + + try { + const storages = await storageApi.getStorages(); + setStorages(storages); + } catch (e) { + alert((e as Error).message); + } + + setIsStoragesLoading(false); + }; + + useEffect(() => { + setIsSaving(false); + setEditingDatabase({ ...database }); + loadStorages(); + + if (database.storage.id) { + setIsShowWarn(true); + } + }, [database]); + + if (!editingDatabase) return null; + + if (isStoragesLoading) + return ( +
+ +
+ ); + + return ( +
+
+ Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.) +
+ +
+
Storages
+ + { + if (!editNotifier) return; + + setEditNotifier({ ...editNotifier, name: e.target.value }); + setIsNameUnsaved(true); + }} + placeholder="Enter name..." + size="large" + /> + +
+ +
+
+ + {isNameUnsaved && ( + + )} +
+ )} + + {notifier.lastSendError && ( +
+
+ + Send error +
+ +
+ The error: +
+ {notifier.lastSendError} +
+ +
+ To clean this error (choose any): +
    +
  • + - send test notification via button below (even if you updated settings); +
  • +
  • - wait until the next notification is sent without errors;
  • +
+
+
+ )} + +
+
Notifier settings
+ + {!isEditSettings ? ( +
startEdit('settings')}> + +
+ ) : ( +
+ )} +
+ +
+ {isEditSettings ? ( + { + setIsEditSettings(false); + setEditNotifier(undefined); + loadSettings(); + }} + isShowName={false} + editingNotifier={notifier} + onChanged={onNotifierChanged} + /> + ) : ( + + )} +
+ + {!isEditSettings && ( +
+ + + +
+ )} +
+ )} + + {isShowRemoveConfirm && ( + setIsShowRemoveConfirm(false)} + description="Are you sure you want to remove this notifier? This action cannot be undone." + actionText="Remove" + actionButtonColor="red" + /> + )} +
+
+ ); +}; diff --git a/frontend/src/features/notifiers/ui/NotifiersComponent.tsx b/frontend/src/features/notifiers/ui/NotifiersComponent.tsx new file mode 100644 index 0000000..256acba --- /dev/null +++ b/frontend/src/features/notifiers/ui/NotifiersComponent.tsx @@ -0,0 +1,108 @@ +import { Button, Modal, Spin } from 'antd'; +import { useEffect, useState } from 'react'; + +import { notifierApi } from '../../../entity/notifiers'; +import type { Notifier } from '../../../entity/notifiers'; +import { NotifierCardComponent } from './NotifierCardComponent'; +import { NotifierComponent } from './NotifierComponent'; +import { EditNotifierComponent } from './edit/EditNotifierComponent'; + +interface Props { + contentHeight: number; +} +export const NotifiersComponent = ({ contentHeight }: Props) => { + const [isLoading, setIsLoading] = useState(true); + const [notifiers, setNotifiers] = useState([]); + + const [isShowAddNotifier, setIsShowAddNotifier] = useState(false); + const [selectedNotifierId, setSelectedNotifierId] = useState(undefined); + const loadNotifiers = () => { + setIsLoading(true); + + notifierApi + .getNotifiers() + .then((notifiers) => { + setNotifiers(notifiers); + if (!selectedNotifierId) { + setSelectedNotifierId(notifiers[0]?.id); + } + }) + .catch((e) => alert(e.message)) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + loadNotifiers(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+ {notifiers.map((notifier) => ( + + ))} + + + +
+ Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.) +
+
+ + {selectedNotifierId && ( + { + loadNotifiers(); + }} + onNotifierDeleted={() => { + loadNotifiers(); + setSelectedNotifierId( + notifiers.filter((notifier) => notifier.id !== selectedNotifierId)[0]?.id, + ); + }} + /> + )} +
+ + {isShowAddNotifier && ( + } + open={isShowAddNotifier} + onCancel={() => setIsShowAddNotifier(false)} + > +
+ Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.) +
+ + setIsShowAddNotifier(false)} + onChanged={() => { + loadNotifiers(); + setIsShowAddNotifier(false); + }} + /> +
+ )} + + ); +}; diff --git a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx new file mode 100644 index 0000000..f70ca53 --- /dev/null +++ b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx @@ -0,0 +1,253 @@ +import { Button, Input, Select } from 'antd'; +import { useEffect, useState } from 'react'; + +import { type Notifier, NotifierType, notifierApi } from '../../../../entity/notifiers'; +import { ToastHelper } from '../../../../shared/toast'; +import { EditEmailNotifierComponent } from './notifiers/EditEmailNotifierComponent'; +import { EditTelegramNotifierComponent } from './notifiers/EditTelegramNotifierComponent'; + +interface Props { + isShowClose: boolean; + onClose: () => void; + + isShowName: boolean; + + editingNotifier?: Notifier; + onChanged: (notifier: Notifier) => void; +} + +export function EditNotifierComponent({ + isShowClose, + onClose, + isShowName, + editingNotifier, + onChanged, +}: Props) { + const [notifier, setNotifier] = useState(); + const [isUnsaved, setIsUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const [isSendingTestNotification, setIsSendingTestNotification] = useState(false); + const [isTestNotificationSuccess, setIsTestNotificationSuccess] = useState(false); + + const save = async () => { + if (!notifier) return; + + setIsSaving(true); + + try { + await notifierApi.saveNotifier(notifier); + onChanged(notifier); + setIsUnsaved(false); + } catch (e) { + alert((e as Error).message); + } + + setIsSaving(false); + }; + + const sendTestNotification = async () => { + if (!notifier) return; + + setIsSendingTestNotification(true); + + try { + await notifierApi.sendTestNotificationDirect(notifier); + setIsTestNotificationSuccess(true); + ToastHelper.showToast({ + title: 'Test notification sent!', + description: 'Test notification sent successfully', + }); + } catch (e) { + alert((e as Error).message); + } + + setIsSendingTestNotification(false); + }; + + const setNotifierType = (type: NotifierType) => { + if (!notifier) return; + + notifier.emailNotifier = undefined; + notifier.telegramNotifier = undefined; + + if (type === NotifierType.TELEGRAM) { + notifier.telegramNotifier = { + botToken: '', + targetChatId: '', + }; + } + + if (type === NotifierType.EMAIL) { + notifier.emailNotifier = { + targetEmail: '', + smtpHost: '', + smtpPort: 0, + smtpUser: '', + smtpPassword: '', + }; + } + + setNotifier( + JSON.parse( + JSON.stringify({ + ...notifier, + notifierType: type, + }), + ), + ); + }; + + useEffect(() => { + setIsUnsaved(false); + setNotifier( + editingNotifier + ? JSON.parse(JSON.stringify(editingNotifier)) + : { + id: undefined as unknown as string, + name: '', + notifierType: NotifierType.TELEGRAM, + telegramNotifier: { + botToken: '', + targetChatId: '', + }, + }, + ); + }, [editingNotifier]); + + const isAllDataFilled = () => { + if (!notifier) return false; + + if (!notifier.name) return false; + + if (notifier.notifierType === NotifierType.TELEGRAM) { + return notifier.telegramNotifier?.botToken && notifier.telegramNotifier?.targetChatId; + } + + if (notifier.notifierType === NotifierType.EMAIL) { + return ( + notifier.emailNotifier?.targetEmail && + notifier.emailNotifier?.smtpHost && + notifier.emailNotifier?.smtpPort && + notifier.emailNotifier?.smtpUser && + notifier.emailNotifier?.smtpPassword + ); + } + + return false; + }; + + if (!notifier) return
; + + return ( +
+ {isShowName && ( +
+
Name
+ + { + setNotifier({ ...notifier, name: e.target.value }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="Chat with me" + /> +
+ )} + +
+
Type
+ + { + if (!notifier?.emailNotifier) return; + + setNotifier({ + ...notifier, + emailNotifier: { + ...notifier.emailNotifier, + targetEmail: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="example@gmail.com" + /> + + + + +
+ +
+
SMTP host
+ { + if (!notifier?.emailNotifier) return; + + setNotifier({ + ...notifier, + emailNotifier: { + ...notifier.emailNotifier, + smtpHost: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="smtp.gmail.com" + /> +
+ +
+
SMTP port
+ { + if (!notifier?.emailNotifier) return; + + setNotifier({ + ...notifier, + emailNotifier: { + ...notifier.emailNotifier, + smtpPort: Number(e.target.value), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="25" + /> +
+ +
+
SMTP user
+ { + if (!notifier?.emailNotifier) return; + + setNotifier({ + ...notifier, + emailNotifier: { + ...notifier.emailNotifier, + smtpUser: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="user@gmail.com" + /> +
+ +
+
SMTP password
+ { + if (!notifier?.emailNotifier) return; + + setNotifier({ + ...notifier, + emailNotifier: { + ...notifier.emailNotifier, + smtpPassword: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="password" + /> +
+ + ); +} diff --git a/frontend/src/features/notifiers/ui/edit/notifiers/EditTelegramNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/notifiers/EditTelegramNotifierComponent.tsx new file mode 100644 index 0000000..c105423 --- /dev/null +++ b/frontend/src/features/notifiers/ui/edit/notifiers/EditTelegramNotifierComponent.tsx @@ -0,0 +1,112 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Input, Tooltip } from 'antd'; +import { useState } from 'react'; + +import type { Notifier } from '../../../../../entity/notifiers'; + +interface Props { + notifier: Notifier; + setNotifier: (notifier: Notifier) => void; + setIsUnsaved: (isUnsaved: boolean) => void; +} + +export function EditTelegramNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) { + const [isShowHowToGetChatId, setIsShowHowToGetChatId] = useState(false); + + return ( + <> +
+
Bot token
+ +
+ { + if (!notifier?.telegramNotifier) return; + setNotifier({ + ...notifier, + telegramNotifier: { + ...notifier.telegramNotifier, + botToken: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full" + placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ" + /> +
+
+ + + +
+
Target chat ID
+ +
+ { + if (!notifier?.telegramNotifier) return; + + setNotifier({ + ...notifier, + telegramNotifier: { + ...notifier.telegramNotifier, + targetChatId: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full" + placeholder="-1001234567890" + /> +
+ + + + +
+ +
+ {!isShowHowToGetChatId ? ( +
setIsShowHowToGetChatId(true)} + > + How to get Telegram chat ID? +
+ ) : ( +
+ To get your chat ID, message{' '} + + @getmyid_bot + {' '} + in Telegram. Make sure you started chat with the bot +
+
+ If you want to get chat ID of a group, add your bot with{' '} + + @getmyid_bot + {' '} + to the group and write /start (you will see chat ID) +
+ )} +
+ + ); +} diff --git a/frontend/src/features/notifiers/ui/show/ShowNotifierComponent.tsx b/frontend/src/features/notifiers/ui/show/ShowNotifierComponent.tsx new file mode 100644 index 0000000..2f4dc9b --- /dev/null +++ b/frontend/src/features/notifiers/ui/show/ShowNotifierComponent.tsx @@ -0,0 +1,32 @@ +import { type Notifier, NotifierType } from '../../../../entity/notifiers'; +import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType'; +import { getNotifierNameFromType } from '../../../../entity/notifiers/models/getNotifierNameFromType'; +import { ShowEmailNotifierComponent } from './notifier/ShowEmailNotifierComponent'; +import { ShowTelegramNotifierComponent } from './notifier/ShowTelegramNotifierComponent'; + +interface Props { + notifier: Notifier; +} + +export function ShowNotifierComponent({ notifier }: Props) { + return ( +
+
+
Type
+ + {getNotifierNameFromType(notifier?.notifierType)} + +
+ +
+ {notifier?.notifierType === NotifierType.TELEGRAM && ( + + )} + + {notifier?.notifierType === NotifierType.EMAIL && ( + + )} +
+
+ ); +} diff --git a/frontend/src/features/notifiers/ui/show/notifier/ShowEmailNotifierComponent.tsx b/frontend/src/features/notifiers/ui/show/notifier/ShowEmailNotifierComponent.tsx new file mode 100644 index 0000000..f9980b3 --- /dev/null +++ b/frontend/src/features/notifiers/ui/show/notifier/ShowEmailNotifierComponent.tsx @@ -0,0 +1,36 @@ +import type { Notifier } from '../../../../../entity/notifiers'; + +interface Props { + notifier: Notifier; +} + +export function ShowEmailNotifierComponent({ notifier }: Props) { + return ( + <> +
+
Target email
+ {notifier?.emailNotifier?.targetEmail} +
+ +
+
SMTP host
+ {notifier?.emailNotifier?.smtpHost} +
+ +
+
SMTP port
+ {notifier?.emailNotifier?.smtpPort} +
+ +
+
SMTP user
+ {notifier?.emailNotifier?.smtpUser} +
+ +
+
SMTP password
+ {notifier?.emailNotifier?.smtpPassword ? '*********' : ''} +
+ + ); +} diff --git a/frontend/src/features/notifiers/ui/show/notifier/ShowTelegramNotifierComponent.tsx b/frontend/src/features/notifiers/ui/show/notifier/ShowTelegramNotifierComponent.tsx new file mode 100644 index 0000000..748eaa0 --- /dev/null +++ b/frontend/src/features/notifiers/ui/show/notifier/ShowTelegramNotifierComponent.tsx @@ -0,0 +1,22 @@ +import type { Notifier } from '../../../../../entity/notifiers'; + +interface Props { + notifier: Notifier; +} + +export function ShowTelegramNotifierComponent({ notifier }: Props) { + return ( + <> +
+
Bot token
+ +
*********
+
+ +
+
Target chat ID
+ {notifier?.telegramNotifier?.targetChatId} +
+ + ); +} diff --git a/frontend/src/features/restores/index.ts b/frontend/src/features/restores/index.ts new file mode 100644 index 0000000..107eb0a --- /dev/null +++ b/frontend/src/features/restores/index.ts @@ -0,0 +1 @@ +export { RestoresComponent } from './ui/RestoresComponent'; diff --git a/frontend/src/features/restores/ui/RestoresComponent.tsx b/frontend/src/features/restores/ui/RestoresComponent.tsx new file mode 100644 index 0000000..f5c6caa --- /dev/null +++ b/frontend/src/features/restores/ui/RestoresComponent.tsx @@ -0,0 +1,238 @@ +import { ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons'; +import { CheckCircleOutlined } from '@ant-design/icons'; +import { Button, Modal, Spin, Tooltip } from 'antd'; +import dayjs from 'dayjs'; +import { useEffect, useRef, useState } from 'react'; + +import type { Backup } from '../../../entity/backups'; +import { type Database, DatabaseType, type PostgresqlDatabase } from '../../../entity/databases'; +import { type Restore, RestoreStatus, restoreApi } from '../../../entity/restores'; +import { getUserTimeFormat } from '../../../shared/time'; +import { EditDatabaseSpecificDataComponent } from '../../databases/ui/edit/EditDatabaseSpecificDataComponent'; + +interface Props { + database: Database; + backup: Backup; +} + +export const RestoresComponent = ({ database, backup }: Props) => { + const [editingDatabase, setEditingDatabase] = useState({ + ...database, + postgresql: database.postgresql + ? ({ + ...database.postgresql, + host: undefined, + port: undefined, + password: undefined, + } as unknown as PostgresqlDatabase) + : undefined, + }); + + const [restores, setRestores] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const [showingRestoreError, setShowingRestoreError] = useState(); + + const [isShowRestore, setIsShowRestore] = useState(false); + + const isReloadInProgress = useRef(false); + + const loadRestores = async () => { + if (isReloadInProgress.current) { + return; + } + + isReloadInProgress.current = true; + + try { + const restores = await restoreApi.getRestores(backup.id); + setRestores(restores); + } catch (e) { + alert((e as Error).message); + } + + isReloadInProgress.current = false; + }; + + const restore = async (editingDatabase: Database) => { + try { + await restoreApi.restoreBackup({ + backupId: backup.id, + postgresql: editingDatabase.postgresql as PostgresqlDatabase, + }); + await loadRestores(); + + setIsShowRestore(false); + } catch (e) { + alert((e as Error).message); + } + }; + + useEffect(() => { + setIsLoading(true); + loadRestores().finally(() => setIsLoading(false)); + + const interval = setInterval(() => { + loadRestores(); + }, 1_000); + + return () => clearInterval(interval); + }, [backup.id]); + + const isRestoreInProgress = restores.some( + (restore) => restore.status === RestoreStatus.IN_PROGRESS, + ); + + if (isShowRestore) { + if (database.type === DatabaseType.POSTGRES) { + return ( + <> +
+ Enter info of the database we will restore backup to. During the restore,{' '} + all the current data will be cleared +
+
+ Make sure the database is not used right now (most likely you do not want to restore the + data to the same DB where the backup was made) +
+ + setIsShowRestore(false)} + isShowBackButton={false} + onBack={() => setIsShowRestore(false)} + saveButtonText="Restore to this DB" + isSaveToApi={false} + onSaved={(database) => { + setEditingDatabase({ ...database }); + restore(database); + }} + isShowDbVersionHint={false} + /> + + ); + } + } + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + + + {restores.length === 0 && ( +
No restores yet
+ )} + +
+ {restores.map((restore) => { + let restoreDurationMs = 0; + if (restore.status === RestoreStatus.IN_PROGRESS) { + restoreDurationMs = Date.now() - new Date(restore.createdAt).getTime(); + } else { + restoreDurationMs = restore.restoreDurationMs; + } + + const minutes = Math.floor(restoreDurationMs / 60000); + const seconds = Math.floor((restoreDurationMs % 60000) / 1000); + const milliseconds = restoreDurationMs % 1000; + const duration = `${minutes}m ${seconds}s ${milliseconds}ms`; + + const backupDurationMs = backup.backupDurationMs; + const expectedRestoreDurationMs = backupDurationMs * 5; + const expectedRestoreDuration = `${Math.floor(expectedRestoreDurationMs / 60000)}m ${Math.floor((expectedRestoreDurationMs % 60000) / 1000)}s`; + + return ( +
+
+
Status
+ + {restore.status === RestoreStatus.FAILED && ( + +
setShowingRestoreError(restore)} + > + + +
Failed
+
+
+ )} + + {restore.status === RestoreStatus.COMPLETED && ( +
+ + +
Successful
+
+ )} + + {restore.status === RestoreStatus.IN_PROGRESS && ( +
+ + In progress +
+ )} +
+ +
+
Started at
+
+ {dayjs.utc(restore.createdAt).local().format(getUserTimeFormat().format)} ( + {dayjs.utc(restore.createdAt).local().fromNow()}) +
+
+ +
+
Duration
+
+
{duration}
+
+ Expected restoration time usually 3x-5x longer than the backup duration + (sometimes less, sometimes more depending on data type) +
+
+ So it is expected to take up to {expectedRestoreDuration} (usually + significantly faster) +
+
+
+
+ ); + })} +
+ + )} + + {showingRestoreError && ( + setShowingRestoreError(undefined)} + footer={null} + > +
{showingRestoreError.failMessage}
+
+ )} +
+ ); +}; diff --git a/frontend/src/features/storages/StorageCardComponent.tsx b/frontend/src/features/storages/StorageCardComponent.tsx new file mode 100644 index 0000000..8260da1 --- /dev/null +++ b/frontend/src/features/storages/StorageCardComponent.tsx @@ -0,0 +1,43 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; + +import { type Storage } from '../../entity/storages'; +import { getStorageLogoFromType } from '../../entity/storages/models/getStorageLogoFromType'; +import { getStorageNameFromType } from '../../entity/storages/models/getStorageNameFromType'; + +interface Props { + storage: Storage; + selectedStorageId?: string; + setSelectedStorageId: (storageId: string) => void; +} + +export const StorageCardComponent = ({ + storage, + selectedStorageId, + setSelectedStorageId, +}: Props) => { + return ( +
setSelectedStorageId(storage.id)} + > +
{storage.name}
+ +
+
Type: {getStorageNameFromType(storage.type)}
+ + storageIcon +
+ + {storage.lastSaveError && ( +
+ + Has save error +
+ )} +
+ ); +}; diff --git a/frontend/src/features/storages/StorageComponent.tsx b/frontend/src/features/storages/StorageComponent.tsx new file mode 100644 index 0000000..4365b1f --- /dev/null +++ b/frontend/src/features/storages/StorageComponent.tsx @@ -0,0 +1,271 @@ +import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import { Button, Input, Spin } from 'antd'; +import { useState } from 'react'; +import { useEffect } from 'react'; + +import { databaseApi } from '../../entity/databases'; +import { storageApi } from '../../entity/storages'; +import type { Storage } from '../../entity/storages'; +import { ToastHelper } from '../../shared/toast'; +import { ConfirmationComponent } from '../../shared/ui'; +import { EditStorageComponent } from './ui/edit/EditStorageComponent'; +import { ShowStorageComponent } from './ui/show/ShowStorageComponent'; + +interface Props { + storageId: string; + onStorageChanged: (storage: Storage) => void; + onStorageDeleted: () => void; +} + +export const StorageComponent = ({ storageId, onStorageChanged, onStorageDeleted }: Props) => { + const [storage, setStorage] = useState(); + + const [isEditName, setIsEditName] = useState(false); + const [isEditSettings, setIsEditSettings] = useState(false); + + const [editStorage, setEditStorage] = useState(); + const [isNameUnsaved, setIsNameUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const [isTestingConnection, setIsTestingConnection] = useState(false); + + const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + const testConnection = () => { + if (!storage) return; + + setIsTestingConnection(true); + storageApi + .testStorageConnection(storage.id) + .then(() => { + ToastHelper.showToast({ + title: 'Connection test successful!', + description: 'Storage connection tested successfully', + }); + + if (storage.lastSaveError) { + setStorage({ ...storage, lastSaveError: undefined }); + onStorageChanged(storage); + } + }) + .catch((e: Error) => { + alert(e.message); + }) + .finally(() => { + setIsTestingConnection(false); + }); + }; + + const remove = async () => { + if (!storage) return; + + setIsRemoving(true); + + try { + const isStorageUsing = await databaseApi.isStorageUsing(storage.id); + if (isStorageUsing) { + alert('Storage is used by some databases. Please remove the storage from databases first.'); + setIsShowRemoveConfirm(false); + } else { + await storageApi.deleteStorage(storage.id); + onStorageDeleted(); + } + } catch (e) { + alert((e as Error).message); + } + + setIsRemoving(false); + }; + + const startEdit = (type: 'name' | 'settings') => { + setEditStorage(JSON.parse(JSON.stringify(storage))); + setIsEditName(type === 'name'); + setIsEditSettings(type === 'settings'); + setIsNameUnsaved(false); + }; + + const saveName = () => { + if (!editStorage) return; + + setIsSaving(true); + storageApi + .saveStorage(editStorage) + .then(() => { + setStorage(editStorage); + setIsSaving(false); + setIsNameUnsaved(false); + setIsEditName(false); + onStorageChanged(editStorage); + }) + .catch((e: Error) => { + alert(e.message); + setIsSaving(false); + }); + }; + + const loadSettings = () => { + setStorage(undefined); + setEditStorage(undefined); + storageApi.getStorage(storageId).then(setStorage); + }; + + useEffect(() => { + loadSettings(); + }, [storageId]); + + return ( +
+
+ {!storage ? ( +
+ +
+ ) : ( +
+ {!isEditName ? ( +
+ {storage.name} +
startEdit('name')}> + +
+
+ ) : ( +
+
+ { + if (!editStorage) return; + + setEditStorage({ ...editStorage, name: e.target.value }); + setIsNameUnsaved(true); + }} + placeholder="Enter name..." + size="large" + /> + +
+ +
+
+ + {isNameUnsaved && ( + + )} +
+ )} + + {storage.lastSaveError && ( +
+
+ + Save error +
+ +
+ The error: +
+ {storage.lastSaveError} +
+ +
+ To clean this error (choose any): +
    +
  • - test connection via button below (even if you updated settings);
  • +
  • - wait until the next save is done without errors;
  • +
+
+
+ )} + +
+
Storage settings
+ + {!isEditSettings ? ( +
startEdit('settings')}> + +
+ ) : ( +
+ )} +
+ +
+ {isEditSettings ? ( + { + setIsEditSettings(false); + setEditStorage(undefined); + loadSettings(); + }} + isShowName={false} + editingStorage={storage} + onChanged={onStorageChanged} + /> + ) : ( + + )} +
+ + {!isEditSettings && ( +
+ + + +
+ )} +
+ )} + + {isShowRemoveConfirm && ( + setIsShowRemoveConfirm(false)} + description="Are you sure you want to remove this storage? This action cannot be undone." + actionText="Remove" + actionButtonColor="red" + /> + )} +
+
+ ); +}; diff --git a/frontend/src/features/storages/StoragesComponent.tsx b/frontend/src/features/storages/StoragesComponent.tsx new file mode 100644 index 0000000..79ab808 --- /dev/null +++ b/frontend/src/features/storages/StoragesComponent.tsx @@ -0,0 +1,110 @@ +import { Button, Modal, Spin } from 'antd'; +import { useEffect, useState } from 'react'; + +import { storageApi } from '../../entity/storages'; +import type { Storage } from '../../entity/storages'; +import { StorageCardComponent } from './StorageCardComponent'; +import { StorageComponent } from './StorageComponent'; +import { EditStorageComponent } from './ui/edit/EditStorageComponent'; + +interface Props { + contentHeight: number; +} + +export const StoragesComponent = ({ contentHeight }: Props) => { + const [isLoading, setIsLoading] = useState(true); + const [storages, setStorages] = useState([]); + + const [isShowAddStorage, setIsShowAddStorage] = useState(false); + const [selectedStorageId, setSelectedStorageId] = useState(undefined); + + const loadStorages = () => { + setIsLoading(true); + + storageApi + .getStorages() + .then((storages: Storage[]) => { + setStorages(storages); + if (!selectedStorageId) { + setSelectedStorageId(storages[0]?.id); + } + }) + .catch((e: Error) => alert(e.message)) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + loadStorages(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+ {storages.map((storage) => ( + + ))} + + + +
+ Storage - is a place where backups will be stored (local disk, S3, etc.) +
+
+ + {selectedStorageId && ( + { + loadStorages(); + }} + onStorageDeleted={() => { + loadStorages(); + setSelectedStorageId( + storages.filter((storage) => storage.id !== selectedStorageId)[0]?.id, + ); + }} + /> + )} +
+ + {isShowAddStorage && ( + } + open={isShowAddStorage} + onCancel={() => setIsShowAddStorage(false)} + > +
+ Storage - is a place where backups will be stored (local disk, S3, etc.) +
+ + setIsShowAddStorage(false)} + onChanged={() => { + loadStorages(); + setIsShowAddStorage(false); + }} + /> +
+ )} + + ); +}; diff --git a/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx new file mode 100644 index 0000000..a0a2e35 --- /dev/null +++ b/frontend/src/features/storages/ui/edit/EditStorageComponent.tsx @@ -0,0 +1,229 @@ +import { Button, Input, Select } from 'antd'; +import { useEffect, useState } from 'react'; + +import { type Storage, StorageType, storageApi } from '../../../../entity/storages'; +import { ToastHelper } from '../../../../shared/toast'; +import { EditS3StorageComponent } from './storages/EditS3StorageComponent'; + +interface Props { + isShowClose: boolean; + onClose: () => void; + + isShowName: boolean; + + editingStorage?: Storage; + onChanged: (storage: Storage) => void; +} + +export function EditStorageComponent({ + isShowClose, + onClose, + isShowName, + editingStorage, + onChanged, +}: Props) { + const [storage, setStorage] = useState(); + const [isUnsaved, setIsUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [isTestConnectionSuccess, setIsTestConnectionSuccess] = useState(false); + + const save = async () => { + if (!storage) return; + + setIsSaving(true); + + try { + await storageApi.saveStorage(storage); + onChanged(storage); + setIsUnsaved(false); + } catch (e) { + alert((e as Error).message); + } + + setIsSaving(false); + }; + + const testConnection = async () => { + if (!storage) return; + + setIsTestingConnection(true); + + try { + await storageApi.testStorageConnectionDirect(storage); + setIsTestConnectionSuccess(true); + ToastHelper.showToast({ + title: 'Connection test successful!', + description: 'Storage connection tested successfully', + }); + } catch (e) { + alert((e as Error).message); + } + + setIsTestingConnection(false); + }; + + const setStorageType = (type: StorageType) => { + if (!storage) return; + + storage.localStorage = undefined; + storage.s3Storage = undefined; + + if (type === StorageType.LOCAL) { + storage.localStorage = {}; + } + + if (type === StorageType.S3) { + storage.s3Storage = { + s3Bucket: '', + s3Region: '', + s3AccessKey: '', + s3SecretKey: '', + s3Endpoint: '', + }; + } + + setStorage( + JSON.parse( + JSON.stringify({ + ...storage, + type: type, + }), + ), + ); + }; + + useEffect(() => { + setIsUnsaved(false); + setStorage( + editingStorage + ? JSON.parse(JSON.stringify(editingStorage)) + : { + id: undefined as unknown as string, + name: '', + type: StorageType.LOCAL, + localStorage: {}, + }, + ); + }, [editingStorage]); + + const isAllDataFilled = () => { + if (!storage) return false; + + if (!storage.name) return false; + + if (storage.type === StorageType.LOCAL) { + return true; // No additional settings required for local storage + } + + if (storage.type === StorageType.S3) { + return ( + storage.s3Storage?.s3Bucket && + storage.s3Storage?.s3Region && + storage.s3Storage?.s3AccessKey && + storage.s3Storage?.s3SecretKey + ); + } + + return false; + }; + + if (!storage) return
; + + return ( +
+ {isShowName && ( +
+
Name
+ + { + setStorage({ ...storage, name: e.target.value }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="My Storage" + /> +
+ )} + +
+
Type
+ + { + if (!storage?.s3Storage) return; + + setStorage({ + ...storage, + s3Storage: { + ...storage.s3Storage, + s3Bucket: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="my-bucket-name" + /> +
+ +
+
Region
+ { + if (!storage?.s3Storage) return; + + setStorage({ + ...storage, + s3Storage: { + ...storage.s3Storage, + s3Region: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="us-east-1" + /> +
+ +
+
Access Key
+ { + if (!storage?.s3Storage) return; + + setStorage({ + ...storage, + s3Storage: { + ...storage.s3Storage, + s3AccessKey: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="AKIAIOSFODNN7EXAMPLE" + /> +
+ +
+
Secret Key
+ { + if (!storage?.s3Storage) return; + + setStorage({ + ...storage, + s3Storage: { + ...storage.s3Storage, + s3SecretKey: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + /> +
+ +
+
Endpoint
+ { + if (!storage?.s3Storage) return; + + setStorage({ + ...storage, + s3Storage: { + ...storage.s3Storage, + s3Endpoint: e.target.value.trim(), + }, + }); + setIsUnsaved(true); + }} + size="small" + className="w-full max-w-[250px]" + placeholder="https://s3.example.com (optional)" + /> + + + + +
+ + ); +} diff --git a/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx b/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx new file mode 100644 index 0000000..1272137 --- /dev/null +++ b/frontend/src/features/storages/ui/show/ShowStorageComponent.tsx @@ -0,0 +1,30 @@ +import { type Storage, StorageType } from '../../../../entity/storages'; +import { getStorageLogoFromType } from '../../../../entity/storages/models/getStorageLogoFromType'; +import { getStorageNameFromType } from '../../../../entity/storages/models/getStorageNameFromType'; +import { ShowS3StorageComponent } from './storages/ShowS3StorageComponent'; + +interface Props { + storage?: Storage; +} + +export function ShowStorageComponent({ storage }: Props) { + if (!storage) return null; + + return ( +
+
+
Type
+ + {getStorageNameFromType(storage.type)} + + storageIcon +
+ +
{storage?.type === StorageType.S3 && }
+
+ ); +} diff --git a/frontend/src/features/storages/ui/show/storages/ShowS3StorageComponent.tsx b/frontend/src/features/storages/ui/show/storages/ShowS3StorageComponent.tsx new file mode 100644 index 0000000..e7b57a9 --- /dev/null +++ b/frontend/src/features/storages/ui/show/storages/ShowS3StorageComponent.tsx @@ -0,0 +1,36 @@ +import type { Storage } from '../../../../../entity/storages'; + +interface Props { + storage: Storage; +} + +export function ShowS3StorageComponent({ storage }: Props) { + return ( + <> +
+
S3 Bucket
+ {storage?.s3Storage?.s3Bucket} +
+ +
+
Region
+ {storage?.s3Storage?.s3Region} +
+ +
+
Access Key
+ {storage?.s3Storage?.s3AccessKey ? '*********' : ''} +
+ +
+
Secret Key
+ {storage?.s3Storage?.s3SecretKey ? '*********' : ''} +
+ +
+
Endpoint
+ {storage?.s3Storage?.s3Endpoint || '-'} +
+ + ); +} diff --git a/frontend/src/features/users/index.ts b/frontend/src/features/users/index.ts new file mode 100644 index 0000000..31fa3bf --- /dev/null +++ b/frontend/src/features/users/index.ts @@ -0,0 +1,3 @@ +export { AuthNavbarComponent } from './ui/AuthNavbarComponent'; +export { SignInComponent } from './ui/SignInComponent'; +export { SignUpComponent } from './ui/SignUpComponent'; diff --git a/frontend/src/features/users/ui/AuthNavbarComponent.tsx b/frontend/src/features/users/ui/AuthNavbarComponent.tsx new file mode 100644 index 0000000..b8dc664 --- /dev/null +++ b/frontend/src/features/users/ui/AuthNavbarComponent.tsx @@ -0,0 +1,37 @@ +export function AuthNavbarComponent() { + return ( + + ); +} diff --git a/frontend/src/features/users/ui/SignInComponent.tsx b/frontend/src/features/users/ui/SignInComponent.tsx new file mode 100644 index 0000000..b102fee --- /dev/null +++ b/frontend/src/features/users/ui/SignInComponent.tsx @@ -0,0 +1,109 @@ +import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons'; +import { Button, Input } from 'antd'; +import { type JSX, useState } from 'react'; + +import { userApi } from '../../../entity/users'; +import { FormValidator } from '../../../shared/lib/FormValidator'; + +export function SignInComponent(): JSX.Element { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordVisible, setPasswordVisible] = useState(false); + + const [isLoading, setLoading] = useState(false); + + const [isEmailError, setEmailError] = useState(false); + const [passwordError, setPasswordError] = useState(false); + + const [signInError, setSignInError] = useState(''); + + const validateFieldsForSignIn = (): boolean => { + if (!email) { + setEmailError(true); + return false; + } + + if (!FormValidator.isValidEmail(email)) { + setEmailError(true); + return false; + } + + if (!password) { + setPasswordError(true); + return false; + } + setPasswordError(false); + + return true; + }; + + const onSignIn = async () => { + setSignInError(''); + + if (validateFieldsForSignIn()) { + setLoading(true); + + try { + await userApi.signIn({ + email, + password, + }); + } catch (e) { + setSignInError((e as Error).message); + } + + setLoading(false); + } + }; + + return ( +
+
Sign in
+ +
Your email
+ { + setEmailError(false); + setEmail(e.currentTarget.value.trim().toLowerCase()); + }} + status={isEmailError ? 'error' : undefined} + type="email" + /> + +
Password
+ { + setPasswordError(false); + setPassword(e.currentTarget.value); + }} + status={passwordError ? 'error' : undefined} + iconRender={(visible) => (visible ? : )} + visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }} + /> + +
+ + + + {signInError && ( +
+ {signInError} +
+ )} +
+ ); +} diff --git a/frontend/src/features/users/ui/SignUpComponent.tsx b/frontend/src/features/users/ui/SignUpComponent.tsx new file mode 100644 index 0000000..f77767d --- /dev/null +++ b/frontend/src/features/users/ui/SignUpComponent.tsx @@ -0,0 +1,145 @@ +import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons'; +import { Button, Input } from 'antd'; +import { type JSX, useState } from 'react'; + +import { userApi } from '../../../entity/users'; +import { FormValidator } from '../../../shared/lib/FormValidator'; + +export function SignUpComponent(): JSX.Element { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordVisible, setPasswordVisible] = useState(false); + const [confirmPassword, setConfirmPassword] = useState(''); + const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false); + + const [isLoading, setLoading] = useState(false); + + const [isEmailError, setEmailError] = useState(false); + const [passwordError, setPasswordError] = useState(false); + const [confirmPasswordError, setConfirmPasswordError] = useState(false); + + const [signUpError, setSignUpError] = useState(''); + + const validateFieldsForSignUp = (): boolean => { + if (!email) { + setEmailError(true); + return false; + } + + if (!FormValidator.isValidEmail(email)) { + setEmailError(true); + return false; + } + + if (!password) { + setPasswordError(true); + return false; + } + + if (password.length < 8) { + setPasswordError(true); + alert('Password must be at least 8 characters long'); + return false; + } + setPasswordError(false); + + if (!confirmPassword) { + setConfirmPasswordError(true); + return false; + } + if (password !== confirmPassword) { + setConfirmPasswordError(true); + return false; + } + setConfirmPasswordError(false); + + return true; + }; + + const onSignUp = async () => { + setSignUpError(''); + + if (validateFieldsForSignUp()) { + setLoading(true); + + try { + await userApi.signUp({ + email, + password, + }); + await userApi.signIn({ email, password }); + } catch (e) { + setSignUpError((e as Error).message); + } + } + + setLoading(false); + }; + + return ( +
+
Sign up
+ +
Your email
+ { + setEmailError(false); + setEmail(e.currentTarget.value.trim().toLowerCase()); + }} + status={isEmailError ? 'error' : undefined} + type="email" + /> + +
Password
+ { + setPasswordError(false); + setPassword(e.currentTarget.value); + }} + status={passwordError ? 'error' : undefined} + iconRender={(visible) => (visible ? : )} + visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }} + /> + +
Confirm password
+ { + setConfirmPasswordError(false); + setConfirmPassword(e.currentTarget.value); + }} + iconRender={(visible) => (visible ? : )} + visibilityToggle={{ + visible: confirmPasswordVisible, + onVisibleChange: setConfirmPasswordVisible, + }} + /> + +
+ + + + {signUpError && ( +
+ {signUpError} +
+ )} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..98177dc --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,50 @@ +@import 'tailwindcss'; + +:root { + font-family: + 'Jost', + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + sans-serif; + line-height: 1.5; + font-weight: 400; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:root, +html, +body { + margin: 0 !important; + padding: 0 !important; +} + +/* START OF SCROLLBAR STYLING */ +*::-webkit-scrollbar { + width: 3px; + padding: 5px; +} + +/* Track */ +*::-webkit-scrollbar-track { + background: gainsboro; +} + +/* Handle */ +*::-webkit-scrollbar-thumb { + background: #adadad; + border-radius: 360px; +} + +/* Handle on hover */ +*::-webkit-scrollbar-thumb:hover { + background: #555; + cursor: pointer; +} +/* END OF SCROLLBAR STYLING */ diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6fdbbb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import utc from 'dayjs/plugin/utc'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import './index.css'; + +import App from './App.tsx'; + +dayjs.extend(utc); +dayjs.extend(relativeTime); + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/pages/AuthPageComponent.tsx b/frontend/src/pages/AuthPageComponent.tsx new file mode 100644 index 0000000..a93b4cf --- /dev/null +++ b/frontend/src/pages/AuthPageComponent.tsx @@ -0,0 +1,45 @@ +import { Spin } from 'antd'; +import { useEffect, useState } from 'react'; + +import { userApi } from '../entity/users'; +import { SignInComponent } from '../features/users'; +import { SignUpComponent } from '../features/users'; +import { AuthNavbarComponent } from '../features/users'; + +export function AuthPageComponent() { + const [isAnyUserExists, setIsAnyUserExists] = useState(false); + const [isLoading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + + userApi + .isAnyUserExists() + .then((isAnyUserExists) => { + setIsAnyUserExists(isAnyUserExists); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( +
+
+ + +
+ {isAnyUserExists ? : } +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/shared/api/RequestOptions.ts b/frontend/src/shared/api/RequestOptions.ts new file mode 100644 index 0000000..8c05456 --- /dev/null +++ b/frontend/src/shared/api/RequestOptions.ts @@ -0,0 +1,67 @@ +export default class RequestOptions { + private headers: [string, string][]; + private method: string | undefined; + private credentials: 'include' | undefined; + private body: string | undefined; + + constructor() { + this.headers = []; + } + + setMethod(method: 'GET' | 'POST' | 'DELETE' | 'PUT'): RequestOptions { + this.method = method; + return this; + } + + setCredentials(credentials: 'include'): RequestOptions { + this.credentials = credentials; + return this; + } + + setBody(body: string): RequestOptions { + this.body = body; + return this; + } + + getBody(): string | undefined { + return this.body; + } + + addHeader(headerName: string, headerValue?: string): RequestOptions { + this.headers.push([headerName, headerValue || '']); + return this; + } + + toRequestInit(): RequestInit { + // Example: + // + // ['Autorization', 'Key'] + // ['Another-Header', 'Another-Value'] + const headersMatrix: string[][] = []; + this.headers.forEach(([headerName, headerValue]) => { + const headerArray: string[] = []; + headerArray.push(headerName); + headerArray.push(headerValue); + headersMatrix.push(headerArray); + }); + + const requestJsonOptions: RequestInit = { + headers: headersMatrix as [string, string][], + cache: 'no-cache', + }; + + if (this.method) { + requestJsonOptions.method = this.method; + } + + if (this.credentials) { + requestJsonOptions.credentials = this.credentials; + } + + if (this.body) { + requestJsonOptions.body = this.body; + } + + return requestJsonOptions; + } +} diff --git a/frontend/src/shared/api/accessTokenHelper.ts b/frontend/src/shared/api/accessTokenHelper.ts new file mode 100644 index 0000000..1872a7c --- /dev/null +++ b/frontend/src/shared/api/accessTokenHelper.ts @@ -0,0 +1,44 @@ +const AUTHORIED_USER_TOKEN_KEY = 'postgresus_user_token'; +const AUTHORIED_USER_ID_KEY = 'postgresus_user_id'; + +export const accessTokenHelper = { + saveAccessToken: (token: string) => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.setItem(AUTHORIED_USER_TOKEN_KEY, token); + }, + + getAccessToken: (): string | undefined => { + if (typeof localStorage === 'undefined') { + return; + } + + return localStorage.getItem(AUTHORIED_USER_TOKEN_KEY) || undefined; + }, + + cleanAccessToken: () => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.removeItem(AUTHORIED_USER_TOKEN_KEY); + }, + + saveUserId: (id: string) => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.setItem(AUTHORIED_USER_ID_KEY, id); + }, + + getUserId: (): string | undefined => { + if (typeof localStorage === 'undefined') { + return; + } + + return localStorage.getItem(AUTHORIED_USER_ID_KEY) || undefined; + }, +}; diff --git a/frontend/src/shared/api/apiHelper.ts b/frontend/src/shared/api/apiHelper.ts new file mode 100644 index 0000000..032bd4c --- /dev/null +++ b/frontend/src/shared/api/apiHelper.ts @@ -0,0 +1,146 @@ +import { accessTokenHelper } from '.'; +import RequestOptions from './RequestOptions'; + +const handleOrThrowMessageIfResponseError = async ( + url: string, + response: Response, + handleNotAuthorizedError = true, +) => { + if (handleNotAuthorizedError && response.status === 401) { + accessTokenHelper?.cleanAccessToken(); + window.location.reload(); + } + + if (response.status === 502 || response.status === 504) { + throw new Error('failed to fetch'); + } + + if (response.status >= 400 && response.status <= 600) { + let errorMessage: string | undefined; + + try { + const json = (await response.json()) as { message?: string; error?: string }; + errorMessage = json.message || json.error; + } catch { + try { + errorMessage = await response.text(); + } catch { + /* ignore */ + } + } + + throw new Error(errorMessage ?? `${url}: ${await response.text()}`); + } +}; + +const makeRequest = async (url: string, optionsWrapper: RequestOptions): Promise => { + const response = await fetch(url, optionsWrapper.toRequestInit()); + await handleOrThrowMessageIfResponseError(url, response); + return response; +}; + +export const apiHelper = { + fetchPostJson: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .setMethod('POST') + .addHeader('Content-Type', 'application/json') + .addHeader('Access-Control-Allow-Methods', 'POST') + .addHeader('Accept', 'application/json') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + + return response.json(); + }, + + fetchPostRaw: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .setMethod('POST') + .addHeader('Content-Type', 'application/json') + .addHeader('Access-Control-Allow-Methods', 'POST') + .addHeader('Accept', 'application/json') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + + return response.text(); + }, + + fetchPostBlob: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .setMethod('POST') + .addHeader('Content-Type', 'application/json') + .addHeader('Access-Control-Allow-Methods', 'POST') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + + return response.blob(); + }, + + fetchGetJson: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .addHeader('Content-Type', 'application/json') + .addHeader('Access-Control-Allow-Methods', 'GET') + .addHeader('Accept', 'application/json') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + return response.json(); + }, + + fetchGetRaw: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .addHeader('Content-Type', 'application/json') + .addHeader('Access-Control-Allow-Methods', 'GET') + .addHeader('Accept', 'application/json') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + return response.text(); + }, + + fetchGetBlob: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .addHeader('Content-Type', 'application/json') + .addHeader('Access-Control-Allow-Methods', 'GET') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + return response.blob(); + }, + + fetchPutJson: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .setMethod('PUT') + .addHeader('Content-Type', 'application/json') + .addHeader('Access-Control-Allow-Methods', 'PUT') + .addHeader('Accept', 'application/json') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + return response.json(); + }, + + fetchDeleteJson: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .setMethod('DELETE') + .addHeader('Access-Control-Allow-Methods', 'DELETE') + .addHeader('Accept', 'application/json') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + return response.json(); + }, + + fetchDeleteRaw: async (url: string, requestOptions?: RequestOptions): Promise => { + const optionsWrapper = (requestOptions ?? new RequestOptions()) + .setMethod('DELETE') + .addHeader('Access-Control-Allow-Methods', 'DELETE') + .addHeader('Accept', 'application/json') + .addHeader('Authorization', accessTokenHelper.getAccessToken()); + + const response = await makeRequest(url, optionsWrapper); + return response.text(); + }, +}; diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts new file mode 100644 index 0000000..6999d4d --- /dev/null +++ b/frontend/src/shared/api/index.ts @@ -0,0 +1,4 @@ +import { accessTokenHelper } from './accessTokenHelper'; +import { apiHelper } from './apiHelper'; + +export { apiHelper, accessTokenHelper }; diff --git a/frontend/src/shared/hooks/index.ts b/frontend/src/shared/hooks/index.ts new file mode 100644 index 0000000..64e9d29 --- /dev/null +++ b/frontend/src/shared/hooks/index.ts @@ -0,0 +1 @@ +export { useScreenHeight } from './useScreenHeight'; diff --git a/frontend/src/shared/hooks/useScreenHeight.tsx b/frontend/src/shared/hooks/useScreenHeight.tsx new file mode 100644 index 0000000..8ece750 --- /dev/null +++ b/frontend/src/shared/hooks/useScreenHeight.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; + +/** + * This hook detects the full screen height + * and adjusts dynamically, particularly for iOS where + * 100vh or 100dvh can behave unexpectedly when the keyboard opens. + * + * It uses visualViewport for better handling on iOS devices. + * + * @returns screenHeight + */ +export function useScreenHeight(): number { + const [screenHeight, setScreenHeight] = useState(900); + + useEffect(() => { + const updateHeight = () => { + const height = window.visualViewport ? window.visualViewport.height : window.innerHeight; + setScreenHeight(height); + }; + + updateHeight(); // Set initial height + window.addEventListener('resize', updateHeight); + + // For devices with visualViewport (like iOS), also listen to viewport changes + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', updateHeight); + } + + return () => { + window.removeEventListener('resize', updateHeight); + if (window.visualViewport) { + window.visualViewport.removeEventListener('resize', updateHeight); + } + }; + }, []); + + return screenHeight; +} diff --git a/frontend/src/shared/lib/FormValidator.ts b/frontend/src/shared/lib/FormValidator.ts new file mode 100644 index 0000000..7fb8915 --- /dev/null +++ b/frontend/src/shared/lib/FormValidator.ts @@ -0,0 +1,16 @@ +export class FormValidator { + static isValidEmail(email: string): boolean { + const emailRegex = /^\S+@\S+\.\S+$/; + return emailRegex.test(email); + } + + static isValidUrl(url: string): boolean { + if (url.replace('https://', '').replace('http://', '').includes('//')) { + return false; + } + + const urlRegex = + /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,9}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/; + return urlRegex.test(url); + } +} diff --git a/frontend/src/shared/lib/index.ts b/frontend/src/shared/lib/index.ts new file mode 100644 index 0000000..95f6a16 --- /dev/null +++ b/frontend/src/shared/lib/index.ts @@ -0,0 +1 @@ +export { FormValidator } from './FormValidator'; diff --git a/frontend/src/shared/time/getUserTimeFormat.ts b/frontend/src/shared/time/getUserTimeFormat.ts new file mode 100644 index 0000000..c0a0bef --- /dev/null +++ b/frontend/src/shared/time/getUserTimeFormat.ts @@ -0,0 +1,11 @@ +export const getUserTimeFormat = () => { + const locale = navigator.language || 'en-US'; + const testDate = new Date(2023, 0, 1, 13, 0, 0); // 1 PM + const timeString = testDate.toLocaleTimeString(locale, { hour: 'numeric' }); + const is12Hour = timeString.includes('PM') || timeString.includes('AM'); + + return { + use12Hours: is12Hour, + format: is12Hour ? 'DD.MM.YYYY h:mm:ss A' : 'DD.MM.YYYY HH:mm:ss', + }; +}; \ No newline at end of file diff --git a/frontend/src/shared/time/index.ts b/frontend/src/shared/time/index.ts new file mode 100644 index 0000000..3897174 --- /dev/null +++ b/frontend/src/shared/time/index.ts @@ -0,0 +1 @@ +export { getUserTimeFormat } from './getUserTimeFormat'; diff --git a/frontend/src/shared/toast/ToastHelper.ts b/frontend/src/shared/toast/ToastHelper.ts new file mode 100644 index 0000000..e7ad238 --- /dev/null +++ b/frontend/src/shared/toast/ToastHelper.ts @@ -0,0 +1,54 @@ +export class ToastHelper { + static showToast({ title, description }: { title: string; description: string }) { + const rootDiv = document.getElementById('blocks-component-root') || document.body; + + if (rootDiv) { + const div = document.createElement('div'); + div.style.backgroundColor = '#fff'; + div.style.color = '#000'; + div.style.padding = '10px'; + div.style.border = '1px solid gainsboro'; + div.style.position = 'fixed'; + div.style.bottom = '-100px'; + div.style.maxWidth = '350px'; + div.style.boxShadow = '0 1rem 3rem rgba(0, 0, 0, .175)'; + div.style.left = '1.5rem'; + div.style.zIndex = '999999'; + div.style.transition = 'top 0.3s ease-in'; + div.style.borderRadius = '5px'; + + const titleDiv = document.createElement('div'); + titleDiv.style.fontWeight = 'bold'; + titleDiv.innerText = title; + titleDiv.style.fontSize = '14px'; + div.appendChild(titleDiv); + + const descriptionDiv = document.createElement('div'); + descriptionDiv.innerText = description; + descriptionDiv.style.fontSize = '14px'; + div.appendChild(descriptionDiv); + + div.onclick = () => { + try { + rootDiv.removeChild(div); + } catch { + // ignore + } + }; + + rootDiv.appendChild(div); + + setTimeout(() => { + div.style.bottom = '1.5rem'; + }, 0); + + setTimeout(() => { + try { + rootDiv.removeChild(div); + } catch { + // ignore + } + }, 3_000); + } + } +} diff --git a/frontend/src/shared/toast/index.ts b/frontend/src/shared/toast/index.ts new file mode 100644 index 0000000..1668859 --- /dev/null +++ b/frontend/src/shared/toast/index.ts @@ -0,0 +1 @@ +export { ToastHelper } from './ToastHelper'; diff --git a/frontend/src/shared/ui/ConfirmationComponent.tsx b/frontend/src/shared/ui/ConfirmationComponent.tsx new file mode 100644 index 0000000..ce54361 --- /dev/null +++ b/frontend/src/shared/ui/ConfirmationComponent.tsx @@ -0,0 +1,58 @@ +import { Button, Modal } from 'antd'; +import type { JSX } from 'react'; + +interface Props { + onConfirm(): void; + onDecline(): void; + + description: string; + actionButtonColor: 'blue' | 'red'; + + actionText: string; + cancelText?: string; + hideCancelButton?: boolean; +} + +export function ConfirmationComponent({ + onConfirm, + onDecline, + description, + actionButtonColor, + actionText, + cancelText, + hideCancelButton = false, +}: Props): JSX.Element { + return ( + onDecline()} + onCancel={() => onDecline()} + footer={
} + > +
+ +
+ {!hideCancelButton && ( + + )} + + +
+ + ); +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts new file mode 100644 index 0000000..c5f7946 --- /dev/null +++ b/frontend/src/shared/ui/index.ts @@ -0,0 +1 @@ +export { ConfirmationComponent } from './ConfirmationComponent'; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/src/widgets/main/MainScreenComponent.tsx b/frontend/src/widgets/main/MainScreenComponent.tsx new file mode 100644 index 0000000..10cc3e9 --- /dev/null +++ b/frontend/src/widgets/main/MainScreenComponent.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; + +import { DatabasesComponent } from '../../features/databases/ui/DatabasesComponent'; +import { NotifiersComponent } from '../../features/notifiers/ui/NotifiersComponent'; +import { StoragesComponent } from '../../features/storages/StoragesComponent'; +import { useScreenHeight } from '../../shared/hooks'; + +export const MainScreenComponent = () => { + const screenHeight = useScreenHeight(); + + const [selectedTab, setSelectedTab] = useState<'notifiers' | 'storages' | 'databases'>( + 'databases', + ); + + const contentHeight = screenHeight - 95; + + return ( +
+ {/* ===================== NAVBAR ===================== */} + + {/* ===================== END NAVBAR ===================== */} + +
+
+ {[ + { + text: 'Databases', + name: 'databases', + icon: '/icons/menu/database-gray.svg', + selectedIcon: '/icons/menu/database-white.svg', + onClick: () => setSelectedTab('databases'), + }, + { + text: 'Storages', + name: 'storages', + icon: '/icons/menu/storage-gray.svg', + selectedIcon: '/icons/menu/storage-white.svg', + onClick: () => setSelectedTab('storages'), + }, + { + text: 'Notifiers', + name: 'notifiers', + icon: '/icons/menu/notifier-gray.svg', + selectedIcon: '/icons/menu/notifier-white.svg', + onClick: () => setSelectedTab('notifiers'), + }, + ].map((tab) => ( +
+
+
+
+ {tab.text} +
+
+
+
+ ))} +
+ + {selectedTab === 'notifiers' && } + {selectedTab === 'storages' && } + {selectedTab === 'databases' && } +
+
+ ); +}; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..9c8a3c4 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d9aa8a8 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..05b56b7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,8 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], +});