commit e5611df24eb420c71f6cc8d44a2bed681ad77e58 Author: Eeveid <448859157@qq.com> Date: Sat Mar 28 15:43:18 2026 +0800 first commit diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..d383e90 --- /dev/null +++ b/backend/.env @@ -0,0 +1,34 @@ +HTTP_ADDR=:8080 +LOG_LEVEL=info +ROOM_TTL_SECONDS=300 + +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +PG_HOST=127.0.0.1 +PG_PORT=5432 +PG_USER=postgres +PG_PASSWORD=123456 +PG_DATABASE=pgcrypto +PG_SSLMODE=disable + +REDIS_ADDR=127.0.0.1:6379 +REDIS_PASSWORD= +REDIS_DB=0 + +MINIO_ENDPOINT=api.minio.zpooi.cn +MINIO_ACCESS_KEY=zpooi +MINIO_SECRET_KEY=sgj.12345 +MINIO_USE_SSL=true +MINIO_BUCKET=filefast-fallback +MINIO_CAPACITY_GB=120 +MINIO_PRESIGN_MINUTES=30 +MINIO_RETENTION_HOURS=2 +MINIO_USAGE_ALERT_PERCENT=85 + +MAX_MINIO_FALLBACK_GB=10 +P2P_CONNECT_TIMEOUT_SEC=15 +TURN_CONNECT_TIMEOUT_SEC=20 +TURN_URLS=turn:turn.zpooi.cn:3478?transport=udp,turn:turn.zpooi.cn:3478?transport=tcp +TURN_USERNAME=filefast +TURN_PASSWORD=sgj.12345 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8e7e7c5 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +.gocache/ +.gomodcache/ +.gopath/ +.tmp +*.exe + diff --git a/backend/cmd/reset_state/main.go b/backend/cmd/reset_state/main.go new file mode 100644 index 0000000..7e9e519 --- /dev/null +++ b/backend/cmd/reset_state/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log" + "time" + + "filefast/backend/internal/config" + "filefast/backend/internal/storage" +) + +func main() { + cfg := config.Load() + + client, err := storage.NewSQLiteClient(cfg.SQLite) + if err != nil { + log.Fatalf("init sqlite client failed: %v", err) + } + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := client.Ping(ctx); err != nil { + log.Fatalf("ping sqlite failed: %v", err) + } + + if err := client.ResetOperationalData(ctx); err != nil { + log.Fatalf("reset operational data failed: %v", err) + } + + log.Printf("reset completed for operational tables in %s", cfg.SQLite.Path) +} diff --git a/backend/cmd/server/backend/data/filefast.db b/backend/cmd/server/backend/data/filefast.db new file mode 100644 index 0000000..f130a36 Binary files /dev/null and b/backend/cmd/server/backend/data/filefast.db differ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..2322b92 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "filefast/backend/internal/config" + "filefast/backend/internal/handler" + "filefast/backend/internal/scheduler" + "filefast/backend/internal/service" + "filefast/backend/internal/storage" + "filefast/backend/internal/store" + "filefast/backend/internal/ws" +) + +func main() { + cfg := config.Load() + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) + memStore := store.NewMemoryStore(cfg.Runtime) + var redisBackplane *storage.RedisClient + storageConnected := false + redisConnected := false + + sqliteClient, err := storage.NewSQLiteClient(cfg.SQLite) + if err != nil { + logger.Error("failed to initialize sqlite client", "path", cfg.SQLite.Path, "error", err) + os.Exit(1) + } + defer sqliteClient.Close() + + storagePingCtx, storagePingCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer storagePingCancel() + if err := sqliteClient.Ping(storagePingCtx); err != nil { + logger.Warn("sqlite connection failed", "path", cfg.SQLite.Path, "error", err) + } else { + storageConnected = true + logger.Info("sqlite connected", "path", cfg.SQLite.Path) + + bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer bootstrapCancel() + + if err := sqliteClient.EnsureSchema(bootstrapCtx); err != nil { + logger.Warn("failed to ensure sqlite schema", "error", err) + } else { + if err := sqliteClient.EnsureAdminUser(bootstrapCtx, cfg.Admin.Username, cfg.Admin.Password); err != nil { + logger.Warn("failed to ensure admin user", "username", cfg.Admin.Username, "error", err) + } + + if err := sqliteClient.ResetOnlineDevices(bootstrapCtx); err != nil { + logger.Warn("failed to reset device online states", "error", err) + } + + snapshot, err := sqliteClient.LoadSnapshot(bootstrapCtx, cfg.Runtime) + if err != nil { + logger.Warn("failed to restore state from sqlite", "error", err) + } else { + memStore.LoadSnapshot(snapshot) + logger.Info( + "restored state from sqlite", + "devices", len(snapshot.Devices), + "rooms", len(snapshot.Rooms), + "transfers", len(snapshot.Transfers), + "fallback_objects", len(snapshot.FallbackObjects), + ) + } + } + + memStore.SetPersistence(sqliteClient, 5*time.Second, func(kind, id string, err error) { + logger.Warn("failed to persist state", "kind", kind, "id", id, "error", err) + }) + } + + redisClient := storage.NewRedisClient(cfg.Redis) + defer redisClient.Close() + + redisPingCtx, redisPingCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer redisPingCancel() + if err := redisClient.Ping(redisPingCtx); err != nil { + logger.Warn("redis connection failed", "addr", cfg.Redis.Addr, "db", cfg.Redis.DB, "error", err) + } else { + redisConnected = true + logger.Info("redis connected", "addr", cfg.Redis.Addr, "db", cfg.Redis.DB) + redisBackplane = redisClient + } + + deviceService := service.NewDeviceService(memStore, redisBackplane, redisBackplane) + roomService := service.NewRoomService(memStore, cfg.RoomTTL) + transferService := service.NewTransferService(memStore) + adminService := service.NewAdminService(memStore, cfg.Admin, redisBackplane, sqliteClient) + + minioClient, err := storage.NewMinIOClient(cfg.MinIO) + if err != nil { + logger.Error("failed to initialize minio client", "error", err) + } + + hub := ws.NewHub(logger, deviceService, redisBackplane) + go hub.Run() + + cleanupScheduler := scheduler.NewCleanupScheduler(logger, memStore, minioClient) + cleanupScheduler.Start() + defer cleanupScheduler.Stop() + + httpHandler := handler.NewHTTPHandler(handler.Dependencies{ + Config: cfg, + Logger: logger, + Store: memStore, + DeviceService: deviceService, + RoomService: roomService, + TransferService: transferService, + AdminService: adminService, + MinIOClient: minioClient, + Hub: hub, + StorageReady: storageConnected, + RedisReady: redisConnected, + }) + + server := &http.Server{ + Addr: cfg.HTTPAddress, + Handler: httpHandler.Router(), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + logger.Info("server listening", "addr", cfg.HTTPAddress) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("server crashed", "error", err) + os.Exit(1) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + logger.Error("server shutdown failed", "error", err) + } + + logger.Info("server stopped") +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..4cd5c0f --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,63 @@ +module filefast/backend + +go 1.25.5 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/minio/minio-go/v7 v7.0.99 + github.com/redis/go-redis/v9 v9.18.0 + github.com/robfig/cron/v3 v3.0.0 + golang.org/x/crypto v0.48.0 + modernc.org/sqlite v1.48.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..6b8d99b --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,182 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +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/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/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.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.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE= +github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= +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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E= +github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..16f3b6d --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,252 @@ +package config + +import ( + "bufio" + "log/slog" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "filefast/backend/internal/model" +) + +type Config struct { + HTTPAddress string + LogLevel slog.Level + RoomTTL time.Duration + Admin AdminConfig + SQLite SQLiteConfig + Redis RedisConfig + MinIO MinIOConfig + Runtime model.RuntimeConfig +} + +type AdminConfig struct { + Username string + Password string +} + +type MinIOConfig struct { + Endpoint string + AccessKey string + SecretKey string + UseSSL bool + Bucket string + PresignExpiry time.Duration + Retention time.Duration + UsageAlertLevel int +} + +type RedisConfig struct { + Addr string + Password string + DB int +} + +type SQLiteConfig struct { + Path string +} + +func Load() Config { + loadDotEnv() + retentionHours := envInt("MINIO_RETENTION_HOURS", 2) + capacityGB := envInt("MINIO_CAPACITY_GB", 120) + return Config{ + HTTPAddress: envString("HTTP_ADDR", ":8080"), + LogLevel: parseLogLevel(envString("LOG_LEVEL", "info")), + RoomTTL: time.Duration(envInt("ROOM_TTL_SECONDS", 300)) * time.Second, + Admin: AdminConfig{ + Username: envString("ADMIN_USERNAME", ""), + Password: envString("ADMIN_PASSWORD", ""), + }, + SQLite: SQLiteConfig{ + Path: envString("SQLITE_PATH", filepath.Join("backend", "data", "filefast.db")), + }, + Redis: RedisConfig{ + Addr: envString("REDIS_ADDR", "127.0.0.1:6379"), + Password: envString("REDIS_PASSWORD", ""), + DB: envInt("REDIS_DB", 0), + }, + MinIO: MinIOConfig{ + Endpoint: envString("MINIO_ENDPOINT", ""), + AccessKey: envString("MINIO_ACCESS_KEY", ""), + SecretKey: envString("MINIO_SECRET_KEY", ""), + UseSSL: envBool("MINIO_USE_SSL", false), + Bucket: envString("MINIO_BUCKET", "filefast-fallback"), + PresignExpiry: time.Duration(envInt("MINIO_PRESIGN_MINUTES", 30)) * time.Minute, + Retention: time.Duration(retentionHours) * time.Hour, + UsageAlertLevel: envInt("MINIO_USAGE_ALERT_PERCENT", 85), + }, + Runtime: model.RuntimeConfig{ + MaxMinIOFallbackSizeBytes: int64(envInt("MAX_MINIO_FALLBACK_GB", 10)) * 1024 * 1024 * 1024, + MinIOCapacityBytes: int64(capacityGB) * 1024 * 1024 * 1024, + MinIORetentionHours: retentionHours, + MinIOUsageAlertPercent: envInt("MINIO_USAGE_ALERT_PERCENT", 85), + P2PConnectTimeoutSec: envInt("P2P_CONNECT_TIMEOUT_SEC", 15), + TURNConnectTimeoutSec: envInt("TURN_CONNECT_TIMEOUT_SEC", 20), + MinIOFallbackEnabled: true, + TURNURLs: envCSV("TURN_URLS"), + TURNUsername: envString("TURN_USERNAME", ""), + TURNPassword: envString("TURN_PASSWORD", ""), + }, + } +} + +func loadDotEnv() { + for _, candidate := range dotEnvCandidates() { + if loadDotEnvFile(candidate) { + return + } + } +} + +func dotEnvCandidates() []string { + cwd, err := os.Getwd() + if err != nil { + return []string{".env", filepath.Join("backend", ".env")} + } + + candidates := make([]string, 0, 16) + seen := make(map[string]struct{}) + current := cwd + + for { + for _, name := range []string{".env", filepath.Join("backend", ".env")} { + candidate := filepath.Clean(filepath.Join(current, name)) + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + candidates = append(candidates, candidate) + } + + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + + // Preserve the most local candidates first, but keep the historical relative + // fallbacks at the end for compatibility. + for _, legacy := range []string{".env", filepath.Join("backend", ".env")} { + if !slices.Contains(candidates, legacy) { + candidates = append(candidates, legacy) + } + } + + return candidates +} + +func loadDotEnvFile(path string) bool { + file, err := os.Open(path) + if err != nil { + return false + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\uFEFF")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + + if len(value) >= 2 { + if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || + (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { + value = value[1 : len(value)-1] + } + } + + _ = os.Setenv(key, value) + } + + return true +} + +func envString(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func envInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + + return parsed +} + +func envBool(key string, fallback bool) bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if value == "" { + return fallback + } + + return value == "1" || value == "true" || value == "yes" +} + +func envCSV(key string) []string { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return nil + } + + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + if value != "" { + values = append(values, value) + } + } + + if len(values) == 0 { + return nil + } + + return values +} + +func parseLogLevel(level string) slog.Level { + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/backend/internal/handler/http.go b/backend/internal/handler/http.go new file mode 100644 index 0000000..7690ad0 --- /dev/null +++ b/backend/internal/handler/http.go @@ -0,0 +1,593 @@ +package handler + +import ( + "context" + "net/http" + "net/url" + "path/filepath" + "strings" + "time" + + "filefast/backend/internal/config" + "filefast/backend/internal/model" + "filefast/backend/internal/service" + "filefast/backend/internal/storage" + "filefast/backend/internal/store" + "filefast/backend/internal/ws" + + "github.com/gin-gonic/gin" + "log/slog" +) + +type Dependencies struct { + Config config.Config + Logger *slog.Logger + Store *store.MemoryStore + DeviceService *service.DeviceService + RoomService *service.RoomService + TransferService *service.TransferService + AdminService *service.AdminService + MinIOClient *storage.MinIOClient + Hub *ws.Hub + StorageReady bool + RedisReady bool +} + +type HTTPHandler struct { + deps Dependencies +} + +func NewHTTPHandler(deps Dependencies) *HTTPHandler { + return &HTTPHandler{deps: deps} +} + +func (h *HTTPHandler) Router() *gin.Engine { + router := gin.Default() + + router.GET("/healthz", h.handleHealth) + router.GET("/ws", h.deps.Hub.Handle) + + api := router.Group("/api") + { + api.GET("/runtime/config", h.runtimeConfig) + api.POST("/devices/register", h.registerDevice) + api.POST("/admin/login", h.adminLogin) + } + + device := api.Group("/") + device.Use(h.requireDevice()) + { + device.POST("/devices/heartbeat", h.deviceHeartbeat) + device.GET("/devices/candidates", h.listCandidates) + device.GET("/devices/:id/pending-downloads", h.pendingFallbackDownloads) + + device.POST("/rooms", h.createRoom) + device.GET("/rooms/:code", h.getRoom) + device.POST("/rooms/join", h.joinRoom) + device.POST("/rooms/:code/cancel", h.cancelRoom) + + device.POST("/transfers", h.createTransfer) + device.PATCH("/transfers/:id/status", h.updateTransferStatus) + device.POST("/transfers/:id/fallback/presign", h.presignFallback) + device.PUT("/transfers/:id/fallback/upload", h.uploadFallback) + device.GET("/transfers/:id/fallback/download", h.downloadFallback) + } + + admin := api.Group("/admin") + admin.Use(h.requireAdmin()) + { + admin.GET("/stats", h.adminStats) + admin.GET("/config", h.adminConfig) + admin.PUT("/config", h.updateAdminConfig) + admin.GET("/transfers/recent", h.recentTransfers) + } + + return router +} + +func (h *HTTPHandler) handleHealth(c *gin.Context) { + status := "ok" + if !h.deps.StorageReady || !h.deps.RedisReady { + status = "degraded" + } + + c.JSON(http.StatusOK, gin.H{ + "status": status, + "minio_enabled": h.deps.MinIOClient != nil && h.deps.MinIOClient.Enabled(), + "storage_ready": h.deps.StorageReady, + "redis_ready": h.deps.RedisReady, + "turn_enabled": len(h.deps.Store.RuntimeConfig().TURNURLs) > 0, + "room_ttl_sec": int(h.deps.Config.RoomTTL.Seconds()), + "server_time_unix": time.Now().Unix(), + }) +} + +func (h *HTTPHandler) runtimeConfig(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.RuntimeConfig()}) +} + +func (h *HTTPHandler) registerDevice(c *gin.Context) { + var input service.RegisterDeviceInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + device, session := h.deps.DeviceService.Register(input, c.Request.UserAgent(), c.GetHeader("X-Device-Token")) + c.JSON(http.StatusOK, gin.H{"data": gin.H{ + "id": device.ID, + "name": device.Name, + "type": device.Type, + "user_agent": device.UserAgent, + "network_group_key": device.NetworkGroupKey, + "public_ip_hash": device.PublicIPHash, + "is_online": device.IsOnline, + "last_seen_at": device.LastSeenAt, + "created_at": device.CreatedAt, + "auth_token": session.Token, + "auth_expires_at": session.ExpiresAt, + }}) +} + +func (h *HTTPHandler) deviceHeartbeat(c *gin.Context) { + var input struct { + DeviceID string `json:"device_id" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if !h.ensureAuthenticatedDevice(c, input.DeviceID) { + return + } + + device, ok := h.deps.DeviceService.Heartbeat(input.DeviceID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "device not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": device}) +} + +func (h *HTTPHandler) listCandidates(c *gin.Context) { + deviceID := c.Query("deviceId") + if deviceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"}) + return + } + if !h.ensureAuthenticatedDevice(c, deviceID) { + return + } + + c.JSON(http.StatusOK, gin.H{"data": h.deps.DeviceService.ListCandidates(deviceID)}) +} + +func (h *HTTPHandler) createRoom(c *gin.Context) { + var input struct { + CreatorDeviceID string `json:"creator_device_id" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !h.ensureAuthenticatedDevice(c, input.CreatorDeviceID) { + return + } + + room, err := h.deps.RoomService.CreateRoom(input.CreatorDeviceID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": room}) +} + +func (h *HTTPHandler) getRoom(c *gin.Context) { + room, ok := h.deps.Store.GetRoom(c.Param("code")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + deviceID := h.authenticatedDeviceID(c) + if room.CreatorDeviceID != deviceID && room.JoinerDeviceID != deviceID { + c.JSON(http.StatusForbidden, gin.H{"error": "room access denied"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": room}) +} + +func (h *HTTPHandler) joinRoom(c *gin.Context) { + var input struct { + Code string `json:"code" binding:"required"` + JoinerDeviceID string `json:"joiner_device_id" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !h.ensureAuthenticatedDevice(c, input.JoinerDeviceID) { + return + } + + room, err := h.deps.RoomService.JoinRoom(input.Code, input.JoinerDeviceID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": room}) +} + +func (h *HTTPHandler) cancelRoom(c *gin.Context) { + var input struct { + RequesterID string `json:"requester_id" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !h.ensureAuthenticatedDevice(c, input.RequesterID) { + return + } + + room, err := h.deps.RoomService.CancelRoom(c.Param("code"), input.RequesterID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": room}) +} + +func (h *HTTPHandler) createTransfer(c *gin.Context) { + var input service.CreateTransferInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !h.ensureAuthenticatedDevice(c, input.SenderDeviceID) { + return + } + + transfer, err := h.deps.TransferService.Create(input) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": transfer}) +} + +func (h *HTTPHandler) updateTransferStatus(c *gin.Context) { + transfer, ok := h.deps.Store.GetTransfer(c.Param("id")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"}) + return + } + deviceID := h.authenticatedDeviceID(c) + if transfer.SenderDeviceID != deviceID && transfer.ReceiverDeviceID != deviceID { + c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"}) + return + } + + var input service.UpdateTransferStatusInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + transfer, err := h.deps.TransferService.UpdateStatus(c.Param("id"), input) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": transfer}) +} + +func (h *HTTPHandler) presignFallback(c *gin.Context) { + if h.deps.MinIOClient == nil || !h.deps.MinIOClient.Enabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "minio fallback is disabled"}) + return + } + + transfer, ok := h.deps.Store.GetTransfer(c.Param("id")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"}) + return + } + if transfer.SenderDeviceID != h.authenticatedDeviceID(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"}) + return + } + + transfer, object, err := h.deps.TransferService.PrepareFallback(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + if err := h.ensureFallbackBucket(ctx, transfer.ID); err != nil { + h.deps.Logger.Warn("minio ensure bucket failed", "transfer_id", transfer.ID, "error", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + uploadURL, err := h.deps.MinIOClient.PresignUpload(ctx, object.ObjectKey) + if err != nil { + h.deps.Logger.Warn("minio presign upload failed", "transfer_id", transfer.ID, "object_key", object.ObjectKey, "error", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + downloadURL, err := h.deps.MinIOClient.PresignDownload(ctx, object.ObjectKey) + if err != nil { + h.deps.Logger.Warn("minio presign download failed", "transfer_id", transfer.ID, "object_key", object.ObjectKey, "error", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "transfer": transfer, + "upload_url": uploadURL.String(), + "download_url": downloadURL.String(), + "download_path": fallbackDownloadPath(transfer.ID), + "expires_at": object.ExpiresAt, + }, + }) +} + +func (h *HTTPHandler) uploadFallback(c *gin.Context) { + if h.deps.MinIOClient == nil || !h.deps.MinIOClient.Enabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "minio fallback is disabled"}) + return + } + + transfer, ok := h.deps.Store.GetTransfer(c.Param("id")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"}) + return + } + if transfer.ObjectKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "fallback object is not prepared"}) + return + } + if transfer.SenderDeviceID != h.authenticatedDeviceID(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Minute) + defer cancel() + + if err := h.ensureFallbackBucket(ctx, transfer.ID); err != nil { + h.deps.Logger.Warn("minio ensure bucket failed", "transfer_id", transfer.ID, "error", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + contentLength := c.Request.ContentLength + if contentLength <= 0 { + contentLength = transfer.SizeBytes + } + + contentType := strings.TrimSpace(c.GetHeader("Content-Type")) + if contentType == "" { + contentType = "application/octet-stream" + } + + if err := h.deps.MinIOClient.UploadObject(ctx, transfer.ObjectKey, c.Request.Body, contentLength, contentType); err != nil { + h.deps.Logger.Warn("minio upload object failed", "transfer_id", transfer.ID, "object_key", transfer.ObjectKey, "error", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + if object, ok := h.deps.Store.GetFallbackObject(transfer.ID); ok { + object.SizeBytes = contentLength + object.CleanupState = "ready" + h.deps.Store.SaveFallbackObject(object) + } + + downloadURL, err := h.deps.MinIOClient.PresignDownload(ctx, transfer.ObjectKey) + if err != nil { + h.deps.Logger.Warn("minio presign download failed after upload", "transfer_id", transfer.ID, "object_key", transfer.ObjectKey, "error", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "download_url": downloadURL.String(), + "download_path": fallbackDownloadPath(transfer.ID), + "object_key": transfer.ObjectKey, + }, + }) +} + +func (h *HTTPHandler) pendingFallbackDownloads(c *gin.Context) { + deviceID := strings.TrimSpace(c.Param("id")) + if deviceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "device id is required"}) + return + } + if !h.ensureAuthenticatedDevice(c, deviceID) { + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": h.deps.Store.ListPendingFallbackDownloads(deviceID, 20), + }) +} + +func (h *HTTPHandler) downloadFallback(c *gin.Context) { + if h.deps.MinIOClient == nil || !h.deps.MinIOClient.Enabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "minio fallback is disabled"}) + return + } + + transfer, ok := h.deps.Store.GetTransfer(c.Param("id")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"}) + return + } + if transfer.ObjectKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "fallback object is not prepared"}) + return + } + if transfer.ReceiverDeviceID != h.authenticatedDeviceID(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) + defer cancel() + + filename := filepath.Base(transfer.Name) + if filename == "." || filename == "" { + filename = "download.bin" + } + + downloadURL, err := h.deps.MinIOClient.PresignDownloadWithFilename(ctx, transfer.ObjectKey, filename) + if err != nil { + h.deps.Logger.Warn("minio presign download failed", "transfer_id", transfer.ID, "object_key", transfer.ObjectKey, "error", err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + c.Redirect(http.StatusTemporaryRedirect, downloadURL.String()) +} + +func (h *HTTPHandler) adminLogin(c *gin.Context) { + var input struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + session, err := h.deps.AdminService.Login(input.Username, input.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": session}) +} + +func (h *HTTPHandler) adminStats(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "stats": h.deps.Store.SnapshotStats(), + "minio": h.deps.Store.SnapshotMinIOStorage(), + }, + }) +} + +func (h *HTTPHandler) adminConfig(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.RuntimeConfig()}) +} + +func (h *HTTPHandler) updateAdminConfig(c *gin.Context) { + var input model.RuntimeConfig + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.UpdateRuntimeConfig(input)}) +} + +func (h *HTTPHandler) recentTransfers(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.ListRecentTransfers(20)}) +} + +func (h *HTTPHandler) requireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if header == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + return + } + + token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer")) + if token == "" || !h.deps.AdminService.ValidateToken(token) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"}) + return + } + + c.Next() + } +} + +func (h *HTTPHandler) requireDevice() gin.HandlerFunc { + return func(c *gin.Context) { + deviceID := strings.TrimSpace(c.GetHeader("X-Device-ID")) + token := strings.TrimSpace(c.GetHeader("X-Device-Token")) + if deviceID == "" || token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing device credentials"}) + return + } + if !h.deps.DeviceService.ValidateSession(deviceID, token) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid device credentials"}) + return + } + c.Set("device_id", deviceID) + c.Next() + } +} + +func (h *HTTPHandler) authenticatedDeviceID(c *gin.Context) string { + value, ok := c.Get("device_id") + if !ok { + return "" + } + deviceID, _ := value.(string) + return deviceID +} + +func (h *HTTPHandler) ensureAuthenticatedDevice(c *gin.Context, expected string) bool { + if h.authenticatedDeviceID(c) != strings.TrimSpace(expected) { + c.JSON(http.StatusForbidden, gin.H{"error": "device access denied"}) + return false + } + return true +} + +func fallbackDownloadPath(transferID string) string { + return "/api/transfers/" + url.PathEscape(transferID) + "/fallback/download" +} + +func contentDisposition(filename string) string { + escaped := strings.ReplaceAll(filename, `"`, "") + return `attachment; filename="` + escaped + `"` +} + +func (h *HTTPHandler) ensureFallbackBucket(ctx context.Context, transferID string) error { + if h.deps.MinIOClient == nil { + return nil + } + + err := h.deps.MinIOClient.EnsureBucket(ctx) + if err == nil { + return nil + } + + // Some MinIO users can read/write objects in an existing bucket but cannot run + // bucket-existence or bucket-creation checks. In that case we keep going. + if strings.Contains(strings.ToLower(err.Error()), "access denied") { + h.deps.Logger.Info("minio bucket ensure skipped due to limited permissions", "transfer_id", transferID) + return nil + } + + return err +} diff --git a/backend/internal/handler/http_test.go b/backend/internal/handler/http_test.go new file mode 100644 index 0000000..fe8109c --- /dev/null +++ b/backend/internal/handler/http_test.go @@ -0,0 +1,195 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "filefast/backend/internal/config" + "filefast/backend/internal/model" + "filefast/backend/internal/service" + "filefast/backend/internal/store" + "filefast/backend/internal/ws" +) + +type registeredDevice struct { + ID string `json:"id"` + AuthToken string `json:"auth_token"` +} + +type transferRecord struct { + ID string `json:"id"` +} + +func TestProtectedRoutesRequireDeviceCredentials(t *testing.T) { + router, _ := newTestRouter() + + device := registerDevice(t, router, map[string]any{ + "device_id": "alpha", + "name": "Alpha", + "type": "desktop", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/devices/candidates?deviceId="+device.ID, nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for missing device credentials, got %d", resp.Code) + } +} + +func TestProtectedRoutesRejectMismatchedDeviceIdentity(t *testing.T) { + router, _ := newTestRouter() + + alpha := registerDevice(t, router, map[string]any{ + "device_id": "alpha", + "name": "Alpha", + "type": "desktop", + }) + bravo := registerDevice(t, router, map[string]any{ + "device_id": "bravo", + "name": "Bravo", + "type": "desktop", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/devices/candidates?deviceId="+bravo.ID, nil) + req.Header.Set("X-Device-ID", alpha.ID) + req.Header.Set("X-Device-Token", alpha.AuthToken) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusForbidden { + t.Fatalf("expected 403 for mismatched device identity, got %d", resp.Code) + } +} + +func TestTransferStatusUpdateRequiresParticipantOwnership(t *testing.T) { + router, _ := newTestRouter() + + sender := registerDevice(t, router, map[string]any{ + "device_id": "sender", + "name": "Sender", + "type": "desktop", + }) + receiver := registerDevice(t, router, map[string]any{ + "device_id": "receiver", + "name": "Receiver", + "type": "desktop", + }) + attacker := registerDevice(t, router, map[string]any{ + "device_id": "attacker", + "name": "Attacker", + "type": "desktop", + }) + + transfer := createTransfer(t, router, sender, map[string]any{ + "kind": "text", + "name": "text-message", + "content": "hello", + "sender_device_id": sender.ID, + "receiver_device_id": receiver.ID, + }) + + body, err := json.Marshal(map[string]any{ + "final_status": "completed", + }) + if err != nil { + t.Fatalf("marshal update status body: %v", err) + } + + req := httptest.NewRequest(http.MethodPatch, "/api/transfers/"+transfer.ID+"/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Device-ID", attacker.ID) + req.Header.Set("X-Device-Token", attacker.AuthToken) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-participant transfer update, got %d", resp.Code) + } +} + +func newTestRouter() (http.Handler, *store.MemoryStore) { + memStore := store.NewMemoryStore(model.RuntimeConfig{}) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + deviceService := service.NewDeviceService(memStore, nil, nil) + deps := Dependencies{ + Config: config.Config{}, + Logger: logger, + Store: memStore, + DeviceService: deviceService, + RoomService: service.NewRoomService(memStore, 0), + TransferService: service.NewTransferService(memStore), + AdminService: service.NewAdminService(memStore, config.AdminConfig{}, nil, nil), + Hub: ws.NewHub(logger, deviceService, nil), + StorageReady: true, + RedisReady: true, + } + + return NewHTTPHandler(deps).Router(), memStore +} + +func registerDevice(t *testing.T, router http.Handler, payload map[string]any) registeredDevice { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal register body: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected register 200, got %d: %s", resp.Code, resp.Body.String()) + } + + var payloadWrapper struct { + Data registeredDevice `json:"data"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &payloadWrapper); err != nil { + t.Fatalf("decode register response: %v", err) + } + if payloadWrapper.Data.ID == "" || payloadWrapper.Data.AuthToken == "" { + t.Fatalf("expected device registration to return id and auth token, got %+v", payloadWrapper.Data) + } + + return payloadWrapper.Data +} + +func createTransfer(t *testing.T, router http.Handler, device registeredDevice, payload map[string]any) transferRecord { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal create transfer body: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/transfers", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Device-ID", device.ID) + req.Header.Set("X-Device-Token", device.AuthToken) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected create transfer 200, got %d: %s", resp.Code, resp.Body.String()) + } + + var payloadWrapper struct { + Data transferRecord `json:"data"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &payloadWrapper); err != nil { + t.Fatalf("decode transfer response: %v", err) + } + if payloadWrapper.Data.ID == "" { + t.Fatalf("expected transfer id in response, got %+v", payloadWrapper.Data) + } + + return payloadWrapper.Data +} diff --git a/backend/internal/model/types.go b/backend/internal/model/types.go new file mode 100644 index 0000000..5735d88 --- /dev/null +++ b/backend/internal/model/types.go @@ -0,0 +1,125 @@ +package model + +import "time" + +const ( + RoomStatusWaiting = "waiting" + RoomStatusJoined = "joined" + RoomStatusCanceled = "canceled" + RoomStatusExpired = "expired" + + ChannelP2P = "p2p" + ChannelTURN = "turn" + ChannelMinIO = "minio" + + TransferPending = "pending" + TransferConnecting = "connecting" + TransferP2PTransferring = "p2p_transferring" + TransferTURNRelaying = "turn_relaying" + TransferFallbackUploading = "fallback_uploading" + TransferCompleted = "completed" + TransferFailed = "failed" + TransferCancelled = "cancelled" +) + +type RuntimeConfig struct { + MaxMinIOFallbackSizeBytes int64 `json:"max_minio_fallback_size_bytes"` + MinIOCapacityBytes int64 `json:"minio_capacity_bytes"` + MinIORetentionHours int `json:"minio_retention_hours"` + MinIOUsageAlertPercent int `json:"minio_usage_alert_percent"` + P2PConnectTimeoutSec int `json:"p2p_connect_timeout_sec"` + TURNConnectTimeoutSec int `json:"turn_connect_timeout_sec"` + MinIOFallbackEnabled bool `json:"minio_fallback_enabled"` + TURNURLs []string `json:"turn_urls"` + TURNUsername string `json:"turn_username"` + TURNPassword string `json:"turn_password"` +} + +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + UserAgent string `json:"user_agent,omitempty"` + NetworkGroupKey string `json:"network_group_key,omitempty"` + PublicIPHash string `json:"public_ip_hash,omitempty"` + IsOnline bool `json:"is_online"` + LastSeenAt time.Time `json:"last_seen_at"` + CreatedAt time.Time `json:"created_at"` +} + +type Room struct { + Code string `json:"code"` + CreatorDeviceID string `json:"creator_device_id"` + JoinerDeviceID string `json:"joiner_device_id,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type Transfer struct { + ID string `json:"id"` + SessionID string `json:"session_id,omitempty"` + Kind string `json:"kind"` + Name string `json:"name"` + Content string `json:"content,omitempty"` + SizeBytes int64 `json:"size_bytes"` + SenderDeviceID string `json:"sender_device_id"` + ReceiverDeviceID string `json:"receiver_device_id"` + TransferStrategy string `json:"transfer_strategy"` + CurrentChannel string `json:"current_channel"` + FallbackAllowed bool `json:"fallback_allowed"` + FinalStatus string `json:"final_status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + FallbackReason string `json:"fallback_reason,omitempty"` + ObjectKey string `json:"object_key,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +type FallbackObject struct { + TransferID string `json:"transfer_id"` + ObjectKey string `json:"object_key"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + CleanedAt *time.Time `json:"cleaned_at,omitempty"` + CleanupState string `json:"cleanup_state"` +} + +type PendingFallbackDownload struct { + TransferID string `json:"transfer_id"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + DownloadPath string `json:"download_path"` + SenderDeviceID string `json:"sender_device_id"` +} + +type MinIOStorageOverview struct { + Enabled bool `json:"enabled"` + UsedBytes int64 `json:"used_bytes"` + CapacityBytes int64 `json:"capacity_bytes"` + RemainingBytes int64 `json:"remaining_bytes"` + UsagePercent int `json:"usage_percent"` + ObjectCount int `json:"object_count"` +} + +type AdminSession struct { + Token string `json:"token"` + CreatedAt time.Time `json:"created_at"` +} + +type DeviceSession struct { + DeviceID string `json:"device_id"` + Token string `json:"token"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type SignalEnvelope struct { + Type string `json:"type"` + DeviceID string `json:"device_id,omitempty"` + TargetDeviceID string `json:"target_device_id,omitempty"` + Payload interface{} `json:"payload,omitempty"` +} diff --git a/backend/internal/scheduler/cleanup.go b/backend/internal/scheduler/cleanup.go new file mode 100644 index 0000000..754baea --- /dev/null +++ b/backend/internal/scheduler/cleanup.go @@ -0,0 +1,59 @@ +package scheduler + +import ( + "context" + "log/slog" + "time" + + "filefast/backend/internal/storage" + "filefast/backend/internal/store" + + "github.com/robfig/cron/v3" +) + +type CleanupScheduler struct { + logger *slog.Logger + store *store.MemoryStore + minioClient *storage.MinIOClient + cron *cron.Cron +} + +func NewCleanupScheduler(logger *slog.Logger, store *store.MemoryStore, minioClient *storage.MinIOClient) *CleanupScheduler { + return &CleanupScheduler{ + logger: logger, + store: store, + minioClient: minioClient, + cron: cron.New(), + } +} + +func (s *CleanupScheduler) Start() { + _, err := s.cron.AddFunc("@daily", s.cleanupExpiredFallbacks) + if err != nil { + s.logger.Error("failed to register cleanup cron", "error", err) + return + } + s.cron.Start() +} + +func (s *CleanupScheduler) Stop() { + ctx := s.cron.Stop() + <-ctx.Done() +} + +func (s *CleanupScheduler) cleanupExpiredFallbacks() { + now := time.Now() + for _, object := range s.store.ListExpiredFallbackObjects(now) { + if s.minioClient != nil { + if err := s.minioClient.RemoveObject(context.Background(), object.ObjectKey); err != nil { + s.logger.Warn("failed to remove expired fallback object", "transfer_id", object.TransferID, "error", err) + continue + } + } + + object.CleanupState = "cleaned" + object.CleanedAt = &now + s.store.SaveFallbackObject(object) + s.logger.Info("cleaned expired fallback object", "transfer_id", object.TransferID, "object_key", object.ObjectKey) + } +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go new file mode 100644 index 0000000..d09c6d5 --- /dev/null +++ b/backend/internal/service/admin_service.go @@ -0,0 +1,95 @@ +package service + +import ( + "context" + "errors" + "time" + + "filefast/backend/internal/config" + "filefast/backend/internal/model" + "filefast/backend/internal/store" + + "github.com/google/uuid" +) + +type AdminService struct { + store *store.MemoryStore + config config.AdminConfig + sessionStore adminSessionStore + authStore adminCredentialStore + sessionTTL time.Duration +} + +type adminSessionStore interface { + SaveAdminSession(context.Context, model.AdminSession, time.Duration) error + HasAdminSession(context.Context, string) (bool, error) +} + +type adminCredentialStore interface { + ValidateAdminCredentials(context.Context, string, string) (bool, error) +} + +func NewAdminService(store *store.MemoryStore, cfg config.AdminConfig, sessionStore adminSessionStore, authStore adminCredentialStore) *AdminService { + return &AdminService{ + store: store, + config: cfg, + sessionStore: sessionStore, + authStore: authStore, + sessionTTL: 24 * time.Hour, + } +} + +func (s *AdminService) Login(username, password string) (model.AdminSession, error) { + if s.authStore != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ok, err := s.authStore.ValidateAdminCredentials(ctx, username, password) + if err == nil { + if !ok { + return model.AdminSession{}, errors.New("invalid admin credentials") + } + return s.issueSession() + } + } + + if username != s.config.Username || password != s.config.Password { + return model.AdminSession{}, errors.New("invalid admin credentials") + } + + return s.issueSession() +} + +func (s *AdminService) issueSession() (model.AdminSession, error) { + session := model.AdminSession{ + Token: uuid.NewString(), + CreatedAt: time.Now(), + } + session = s.store.SaveAdminSession(session) + + if s.sessionStore != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.sessionStore.SaveAdminSession(ctx, session, s.sessionTTL); err != nil { + return model.AdminSession{}, err + } + } + + return session, nil +} + +func (s *AdminService) ValidateToken(token string) bool { + if s.store.HasAdminSession(token) { + return true + } + + if s.sessionStore == nil { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ok, err := s.sessionStore.HasAdminSession(ctx, token) + return err == nil && ok +} diff --git a/backend/internal/service/device_service.go b/backend/internal/service/device_service.go new file mode 100644 index 0000000..975d428 --- /dev/null +++ b/backend/internal/service/device_service.go @@ -0,0 +1,210 @@ +package service + +import ( + "context" + "sort" + "strings" + "time" + + "filefast/backend/internal/model" + "filefast/backend/internal/store" + + "github.com/google/uuid" +) + +type DeviceService struct { + store *store.MemoryStore + presenceStore devicePresenceStore + sessionStore deviceSessionStore + presenceTTL time.Duration + sessionTTL time.Duration +} + +type devicePresenceStore interface { + SetDevicePresence(context.Context, string, bool, time.Time, time.Duration) error + GetDevicePresence(context.Context, []string) (map[string]bool, error) +} + +type deviceSessionStore interface { + SaveDeviceSession(context.Context, model.DeviceSession, time.Duration) error + ValidateDeviceSession(context.Context, string, string) (bool, error) +} + +type RegisterDeviceInput struct { + DeviceID string `json:"device_id"` + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + NetworkGroupKey string `json:"network_group_key"` + PublicIPHash string `json:"public_ip_hash"` +} + +func NewDeviceService(store *store.MemoryStore, presenceStore devicePresenceStore, sessionStore deviceSessionStore) *DeviceService { + return &DeviceService{ + store: store, + presenceStore: presenceStore, + sessionStore: sessionStore, + presenceTTL: 45 * time.Second, + sessionTTL: 30 * 24 * time.Hour, + } +} + +func (s *DeviceService) Register(input RegisterDeviceInput, userAgent, claimedToken string) (model.Device, model.DeviceSession) { + now := time.Now() + id := strings.TrimSpace(input.DeviceID) + if id == "" { + id = uuid.NewString() + } + + device, exists := s.store.GetDevice(id) + if exists && !s.ValidateSession(id, strings.TrimSpace(claimedToken)) { + id = uuid.NewString() + exists = false + } + if !exists { + device = model.Device{ + ID: id, + CreatedAt: now, + } + } + + device.Name = input.Name + device.Type = input.Type + device.UserAgent = userAgent + device.NetworkGroupKey = input.NetworkGroupKey + device.PublicIPHash = input.PublicIPHash + device.LastSeenAt = now + device.IsOnline = true + + device = s.store.UpsertDevice(device) + session := s.issueSession(device.ID) + s.syncPresence(device) + return device, session +} + +func (s *DeviceService) Heartbeat(deviceID string) (model.Device, bool) { + device, ok := s.store.GetDevice(deviceID) + if !ok { + return model.Device{}, false + } + + device.LastSeenAt = time.Now() + device.IsOnline = true + device = s.store.UpsertDevice(device) + s.syncPresence(device) + return device, true +} + +func (s *DeviceService) SetOnline(deviceID string, online bool) (model.Device, bool) { + device, ok := s.store.GetDevice(deviceID) + if !ok { + return model.Device{}, false + } + + device.IsOnline = online + device.LastSeenAt = time.Now() + device = s.store.UpsertDevice(device) + s.syncPresence(device) + return device, true +} + +func (s *DeviceService) ListCandidates(currentDeviceID string) []model.Device { + current, _ := s.store.GetDevice(currentDeviceID) + devices := s.store.ListDevices() + s.applyPresence(devices) + candidates := make([]model.Device, 0, len(devices)) + + for _, device := range devices { + if device.ID == currentDeviceID || !device.IsOnline { + continue + } + candidates = append(candidates, device) + } + + sort.SliceStable(candidates, func(i, j int) bool { + leftSameNetwork := current.NetworkGroupKey != "" && candidates[i].NetworkGroupKey == current.NetworkGroupKey + rightSameNetwork := current.NetworkGroupKey != "" && candidates[j].NetworkGroupKey == current.NetworkGroupKey + if leftSameNetwork != rightSameNetwork { + return leftSameNetwork + } + return candidates[i].LastSeenAt.After(candidates[j].LastSeenAt) + }) + + return candidates +} + +func (s *DeviceService) ValidateSession(deviceID, token string) bool { + deviceID = strings.TrimSpace(deviceID) + token = strings.TrimSpace(token) + if deviceID == "" || token == "" { + return false + } + + if s.store.ValidateDeviceSession(deviceID, token) { + return true + } + + if s.sessionStore == nil { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + ok, err := s.sessionStore.ValidateDeviceSession(ctx, deviceID, token) + return err == nil && ok +} + +func (s *DeviceService) issueSession(deviceID string) model.DeviceSession { + session := model.DeviceSession{ + DeviceID: deviceID, + Token: uuid.NewString(), + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(s.sessionTTL), + } + session = s.store.SaveDeviceSession(session) + + if s.sessionStore != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.sessionStore.SaveDeviceSession(ctx, session, s.sessionTTL) + } + + return session +} + +func (s *DeviceService) syncPresence(device model.Device) { + if s.presenceStore == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.presenceStore.SetDevicePresence(ctx, device.ID, device.IsOnline, device.LastSeenAt, s.presenceTTL) +} + +func (s *DeviceService) applyPresence(devices []model.Device) { + if s.presenceStore == nil || len(devices) == 0 { + return + } + + deviceIDs := make([]string, 0, len(devices)) + for _, device := range devices { + if device.ID != "" { + deviceIDs = append(deviceIDs, device.ID) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + statuses, err := s.presenceStore.GetDevicePresence(ctx, deviceIDs) + if err != nil { + return + } + + for index := range devices { + if online, ok := statuses[devices[index].ID]; ok { + devices[index].IsOnline = online + } + } +} diff --git a/backend/internal/service/device_service_test.go b/backend/internal/service/device_service_test.go new file mode 100644 index 0000000..ef5baf0 --- /dev/null +++ b/backend/internal/service/device_service_test.go @@ -0,0 +1,58 @@ +package service + +import ( + "testing" + + "filefast/backend/internal/model" + "filefast/backend/internal/store" +) + +func TestRegisterReusesKnownDeviceOnlyWithValidToken(t *testing.T) { + memStore := store.NewMemoryStore(model.RuntimeConfig{}) + deviceService := NewDeviceService(memStore, nil, nil) + + device, session := deviceService.Register(RegisterDeviceInput{ + DeviceID: "known-device", + Name: "Alpha", + Type: "desktop", + }, "ua/1.0", "") + + if device.ID != "known-device" { + t.Fatalf("expected first registration to keep requested device id, got %q", device.ID) + } + if !deviceService.ValidateSession(device.ID, session.Token) { + t.Fatal("expected issued device token to validate") + } + + hijacked, hijackedSession := deviceService.Register(RegisterDeviceInput{ + DeviceID: "known-device", + Name: "Mallory", + Type: "desktop", + }, "ua/1.0", "") + + if hijacked.ID == device.ID { + t.Fatal("expected registration without token to receive a new device id") + } + if !deviceService.ValidateSession(hijacked.ID, hijackedSession.Token) { + t.Fatal("expected replacement device token to validate") + } + + restored, rotatedSession := deviceService.Register(RegisterDeviceInput{ + DeviceID: "known-device", + Name: "Alpha", + Type: "desktop", + }, "ua/1.0", session.Token) + + if restored.ID != device.ID { + t.Fatalf("expected valid token to reclaim original device id, got %q", restored.ID) + } + if rotatedSession.Token == session.Token { + t.Fatal("expected registration to rotate the device token") + } + if deviceService.ValidateSession(restored.ID, session.Token) { + t.Fatal("expected rotated token to invalidate the old token") + } + if !deviceService.ValidateSession(restored.ID, rotatedSession.Token) { + t.Fatal("expected rotated device token to validate") + } +} diff --git a/backend/internal/service/room_service.go b/backend/internal/service/room_service.go new file mode 100644 index 0000000..2353402 --- /dev/null +++ b/backend/internal/service/room_service.go @@ -0,0 +1,84 @@ +package service + +import ( + "errors" + "fmt" + "math/rand/v2" + "time" + + "filefast/backend/internal/model" + "filefast/backend/internal/store" +) + +type RoomService struct { + store *store.MemoryStore + ttl time.Duration +} + +func NewRoomService(store *store.MemoryStore, ttl time.Duration) *RoomService { + return &RoomService{ + store: store, + ttl: ttl, + } +} + +func (s *RoomService) CreateRoom(creatorDeviceID string) (model.Room, error) { + if creatorDeviceID == "" { + return model.Room{}, errors.New("creator_device_id is required") + } + + now := time.Now() + for range 20 { + code := fmt.Sprintf("%04d", rand.IntN(10000)) + if room, ok := s.store.GetRoom(code); ok && room.ExpiresAt.After(now) && room.Status != model.RoomStatusExpired { + continue + } + + room := model.Room{ + Code: code, + CreatorDeviceID: creatorDeviceID, + Status: model.RoomStatusWaiting, + CreatedAt: now, + ExpiresAt: now.Add(s.ttl), + } + return s.store.UpsertRoom(room), nil + } + + return model.Room{}, errors.New("failed to allocate room code") +} + +func (s *RoomService) JoinRoom(code, joinerDeviceID string) (model.Room, error) { + room, ok := s.store.GetRoom(code) + if !ok { + return model.Room{}, errors.New("room not found") + } + + now := time.Now() + if !room.ExpiresAt.After(now) { + room.Status = model.RoomStatusExpired + s.store.UpsertRoom(room) + return model.Room{}, errors.New("room expired") + } + + if room.Status != model.RoomStatusWaiting { + return model.Room{}, errors.New("room unavailable") + } + + room.JoinerDeviceID = joinerDeviceID + room.Status = model.RoomStatusJoined + return s.store.UpsertRoom(room), nil +} + +func (s *RoomService) CancelRoom(code, requesterID string) (model.Room, error) { + room, ok := s.store.GetRoom(code) + if !ok { + return model.Room{}, errors.New("room not found") + } + + if room.CreatorDeviceID != requesterID { + return model.Room{}, errors.New("only creator can cancel room") + } + + room.Status = model.RoomStatusCanceled + return s.store.UpsertRoom(room), nil +} diff --git a/backend/internal/service/transfer_service.go b/backend/internal/service/transfer_service.go new file mode 100644 index 0000000..3c9e58b --- /dev/null +++ b/backend/internal/service/transfer_service.go @@ -0,0 +1,138 @@ +package service + +import ( + "errors" + "fmt" + "time" + + "filefast/backend/internal/model" + "filefast/backend/internal/store" + + "github.com/google/uuid" +) + +type TransferService struct { + store *store.MemoryStore +} + +type CreateTransferInput struct { + SessionID string `json:"session_id"` + Kind string `json:"kind" binding:"required"` + Name string `json:"name"` + Content string `json:"content"` + SizeBytes int64 `json:"size_bytes"` + SenderDeviceID string `json:"sender_device_id" binding:"required"` + ReceiverDeviceID string `json:"receiver_device_id" binding:"required"` +} + +type UpdateTransferStatusInput struct { + CurrentChannel string `json:"current_channel"` + FinalStatus string `json:"final_status"` + FallbackReason string `json:"fallback_reason"` +} + +func NewTransferService(store *store.MemoryStore) *TransferService { + return &TransferService{store: store} +} + +func (s *TransferService) Create(input CreateTransferInput) (model.Transfer, error) { + switch input.Kind { + case "file": + if input.Name == "" { + return model.Transfer{}, errors.New("file name is required") + } + if input.SizeBytes <= 0 { + return model.Transfer{}, errors.New("file size must be greater than zero") + } + case "text": + if input.Content == "" { + return model.Transfer{}, errors.New("text content is required") + } + if input.Name == "" { + input.Name = "text-message" + } + default: + return model.Transfer{}, errors.New("unsupported transfer kind") + } + + runtime := s.store.RuntimeConfig() + now := time.Now() + fallbackAllowed := input.Kind == "file" && runtime.MinIOFallbackEnabled + strategy := "p2p_turn" + if fallbackAllowed { + strategy = "p2p_turn_minio" + } + + transfer := model.Transfer{ + ID: uuid.NewString(), + SessionID: input.SessionID, + Kind: input.Kind, + Name: input.Name, + Content: input.Content, + SizeBytes: input.SizeBytes, + SenderDeviceID: input.SenderDeviceID, + ReceiverDeviceID: input.ReceiverDeviceID, + TransferStrategy: strategy, + CurrentChannel: model.ChannelP2P, + FallbackAllowed: fallbackAllowed, + FinalStatus: model.TransferPending, + CreatedAt: now, + UpdatedAt: now, + } + + return s.store.UpsertTransfer(transfer), nil +} + +func (s *TransferService) UpdateStatus(transferID string, input UpdateTransferStatusInput) (model.Transfer, error) { + transfer, ok := s.store.GetTransfer(transferID) + if !ok { + return model.Transfer{}, errors.New("transfer not found") + } + + if input.CurrentChannel != "" { + transfer.CurrentChannel = input.CurrentChannel + } + if input.FinalStatus != "" { + transfer.FinalStatus = input.FinalStatus + } + if input.FallbackReason != "" { + transfer.FallbackReason = input.FallbackReason + } + + transfer.UpdatedAt = time.Now() + return s.store.UpsertTransfer(transfer), nil +} + +func (s *TransferService) PrepareFallback(transferID string) (model.Transfer, model.FallbackObject, error) { + transfer, ok := s.store.GetTransfer(transferID) + if !ok { + return model.Transfer{}, model.FallbackObject{}, errors.New("transfer not found") + } + if !transfer.FallbackAllowed { + return model.Transfer{}, model.FallbackObject{}, errors.New("transfer cannot use minio fallback") + } + if object, ok := s.store.GetFallbackObject(transfer.ID); ok && object.CleanedAt == nil && object.ExpiresAt.After(time.Now()) { + return transfer, object, nil + } + + runtime := s.store.RuntimeConfig() + now := time.Now() + expireAt := now.Add(time.Duration(runtime.MinIORetentionHours) * time.Hour) + objectKey := fmt.Sprintf("fallback/%s/%d-%s", now.Format("20060102"), now.Unix(), transfer.ID) + + transfer.ObjectKey = objectKey + transfer.ExpiresAt = &expireAt + transfer.UpdatedAt = now + transfer = s.store.UpsertTransfer(transfer) + + object := model.FallbackObject{ + TransferID: transfer.ID, + ObjectKey: objectKey, + SizeBytes: transfer.SizeBytes, + CreatedAt: now, + ExpiresAt: expireAt, + CleanupState: "uploading", + } + object = s.store.SaveFallbackObject(object) + return transfer, object, nil +} diff --git a/backend/internal/storage/minio.go b/backend/internal/storage/minio.go new file mode 100644 index 0000000..d861e6e --- /dev/null +++ b/backend/internal/storage/minio.go @@ -0,0 +1,141 @@ +package storage + +import ( + "context" + "errors" + "io" + "net/url" + "strings" + "time" + + "filefast/backend/internal/config" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type MinIOClient struct { + client *minio.Client + bucket string + presignExpiry time.Duration + enabled bool +} + +func NewMinIOClient(cfg config.MinIOConfig) (*MinIOClient, error) { + if cfg.Endpoint == "" || cfg.AccessKey == "" || cfg.SecretKey == "" { + return &MinIOClient{ + bucket: cfg.Bucket, + presignExpiry: cfg.PresignExpiry, + enabled: false, + }, nil + } + + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, err + } + + return &MinIOClient{ + client: client, + bucket: cfg.Bucket, + presignExpiry: cfg.PresignExpiry, + enabled: true, + }, nil +} + +func (c *MinIOClient) Enabled() bool { + return c != nil && c.enabled +} + +func (c *MinIOClient) EnsureBucket(ctx context.Context) error { + if !c.Enabled() { + return nil + } + + exists, err := c.client.BucketExists(ctx, c.bucket) + if err != nil { + return err + } + if exists { + return nil + } + return c.client.MakeBucket(ctx, c.bucket, minio.MakeBucketOptions{}) +} + +func (c *MinIOClient) PresignUpload(ctx context.Context, objectKey string) (*url.URL, error) { + if !c.Enabled() { + return nil, errors.New("minio is disabled") + } + return c.client.PresignedPutObject(ctx, c.bucket, objectKey, c.presignExpiry) +} + +func (c *MinIOClient) PresignDownload(ctx context.Context, objectKey string) (*url.URL, error) { + return c.PresignDownloadWithFilename(ctx, objectKey, "") +} + +func (c *MinIOClient) PresignDownloadWithFilename(ctx context.Context, objectKey, filename string) (*url.URL, error) { + if !c.Enabled() { + return nil, errors.New("minio is disabled") + } + + var reqParams url.Values + if filename = sanitizeDownloadFilename(filename); filename != "" { + reqParams = make(url.Values) + reqParams.Set("response-content-disposition", `attachment; filename="`+filename+`"`) + } + + return c.client.PresignedGetObject(ctx, c.bucket, objectKey, c.presignExpiry, reqParams) +} + +func (c *MinIOClient) UploadObject(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error { + if !c.Enabled() { + return errors.New("minio is disabled") + } + + options := minio.PutObjectOptions{ + ContentType: contentType, + } + _, err := c.client.PutObject(ctx, c.bucket, objectKey, reader, size, options) + return err +} + +func (c *MinIOClient) OpenObject(ctx context.Context, objectKey string) (*minio.Object, minio.ObjectInfo, error) { + if !c.Enabled() { + return nil, minio.ObjectInfo{}, errors.New("minio is disabled") + } + + object, err := c.client.GetObject(ctx, c.bucket, objectKey, minio.GetObjectOptions{}) + if err != nil { + return nil, minio.ObjectInfo{}, err + } + + info, err := object.Stat() + if err != nil { + _ = object.Close() + return nil, minio.ObjectInfo{}, err + } + + return object, info, nil +} + +func (c *MinIOClient) RemoveObject(ctx context.Context, objectKey string) error { + if !c.Enabled() { + return nil + } + return c.client.RemoveObject(ctx, c.bucket, objectKey, minio.RemoveObjectOptions{}) +} + +func sanitizeDownloadFilename(filename string) string { + filename = strings.TrimSpace(filename) + if filename == "" { + return "" + } + + filename = strings.ReplaceAll(filename, `"`, "") + filename = strings.ReplaceAll(filename, "\r", "") + filename = strings.ReplaceAll(filename, "\n", "") + return filename +} diff --git a/backend/internal/storage/redis.go b/backend/internal/storage/redis.go new file mode 100644 index 0000000..4785cdf --- /dev/null +++ b/backend/internal/storage/redis.go @@ -0,0 +1,205 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "filefast/backend/internal/config" + "filefast/backend/internal/model" + + "github.com/redis/go-redis/v9" +) + +const ( + RedisRealtimeRelayChannel = "filefast:ws:relay" + RedisRealtimePresenceChannel = "filefast:ws:presence" +) + +type RedisClient struct { + client *redis.Client +} + +type RealtimeMessage struct { + Channel string + Payload []byte +} + +func NewRedisClient(cfg config.RedisConfig) *RedisClient { + client := redis.NewClient(&redis.Options{ + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, + MaxRetries: 1, + DialTimeout: 3 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + }) + + return &RedisClient{client: client} +} + +func (c *RedisClient) Ping(ctx context.Context) error { + if !c.available() { + return redis.ErrClosed + } + return c.client.Ping(ctx).Err() +} + +func (c *RedisClient) SaveAdminSession(ctx context.Context, session model.AdminSession, ttl time.Duration) error { + if !c.available() { + return redis.ErrClosed + } + + payload, err := json.Marshal(session) + if err != nil { + return err + } + + return c.client.Set(ctx, adminSessionKey(session.Token), payload, ttl).Err() +} + +func (c *RedisClient) HasAdminSession(ctx context.Context, token string) (bool, error) { + if !c.available() { + return false, redis.ErrClosed + } + + result, err := c.client.Exists(ctx, adminSessionKey(token)).Result() + if err != nil { + return false, err + } + + return result > 0, nil +} + +func (c *RedisClient) SaveDeviceSession(ctx context.Context, session model.DeviceSession, ttl time.Duration) error { + if !c.available() { + return redis.ErrClosed + } + + return c.client.Set(ctx, deviceSessionKey(session.DeviceID), session.Token, ttl).Err() +} + +func (c *RedisClient) ValidateDeviceSession(ctx context.Context, deviceID, token string) (bool, error) { + if !c.available() { + return false, redis.ErrClosed + } + + value, err := c.client.Get(ctx, deviceSessionKey(deviceID)).Result() + if err != nil { + if err == redis.Nil { + return false, nil + } + return false, err + } + + return value == token, nil +} + +func (c *RedisClient) SetDevicePresence(ctx context.Context, deviceID string, online bool, lastSeen time.Time, ttl time.Duration) error { + if !c.available() { + return redis.ErrClosed + } + + key := devicePresenceKey(deviceID) + if !online { + return c.client.Del(ctx, key).Err() + } + + return c.client.Set(ctx, key, lastSeen.UTC().Format(time.RFC3339Nano), ttl).Err() +} + +func (c *RedisClient) GetDevicePresence(ctx context.Context, deviceIDs []string) (map[string]bool, error) { + if !c.available() { + return nil, redis.ErrClosed + } + + statuses := make(map[string]bool, len(deviceIDs)) + if len(deviceIDs) == 0 { + return statuses, nil + } + + keys := make([]string, 0, len(deviceIDs)) + for _, id := range deviceIDs { + keys = append(keys, devicePresenceKey(id)) + } + + values, err := c.client.MGet(ctx, keys...).Result() + if err != nil { + return nil, err + } + + for index, id := range deviceIDs { + statuses[id] = values[index] != nil + } + + return statuses, nil +} + +func (c *RedisClient) PublishRealtime(ctx context.Context, channel string, payload []byte) error { + if !c.available() { + return redis.ErrClosed + } + return c.client.Publish(ctx, channel, payload).Err() +} + +func (c *RedisClient) SubscribeRealtime(ctx context.Context, channels ...string) (<-chan RealtimeMessage, error) { + if !c.available() { + return nil, redis.ErrClosed + } + + pubsub := c.client.Subscribe(ctx, channels...) + if _, err := pubsub.Receive(ctx); err != nil { + _ = pubsub.Close() + return nil, err + } + + source := pubsub.Channel() + messages := make(chan RealtimeMessage, 64) + + go func() { + defer close(messages) + defer pubsub.Close() + + for { + select { + case <-ctx.Done(): + return + case message, ok := <-source: + if !ok { + return + } + messages <- RealtimeMessage{ + Channel: message.Channel, + Payload: []byte(message.Payload), + } + } + } + }() + + return messages, nil +} + +func (c *RedisClient) Close() error { + if !c.available() { + return nil + } + return c.client.Close() +} + +func (c *RedisClient) available() bool { + return c != nil && c.client != nil +} + +func adminSessionKey(token string) string { + return fmt.Sprintf("filefast:admin:session:%s", token) +} + +func devicePresenceKey(deviceID string) string { + return fmt.Sprintf("filefast:device:online:%s", deviceID) +} + +func deviceSessionKey(deviceID string) string { + return fmt.Sprintf("filefast:device:session:%s", deviceID) +} diff --git a/backend/internal/storage/shared.go b/backend/internal/storage/shared.go new file mode 100644 index 0000000..3239ca4 --- /dev/null +++ b/backend/internal/storage/shared.go @@ -0,0 +1,125 @@ +package storage + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "filefast/backend/internal/model" +) + +const ( + runtimeConfigKey = "transfer_policy" +) + +type runtimeConfigPayload struct { + MaxMinIOFallbackSizeBytes *int64 `json:"max_minio_fallback_size_bytes"` + MaxMinIOFallbackGB *int64 `json:"max_minio_fallback_gb"` + MinIOCapacityBytes *int64 `json:"minio_capacity_bytes"` + MinIOCapacityGB *int64 `json:"minio_capacity_gb"` + MinIORetentionHours *int `json:"minio_retention_hours"` + MinIOUsageAlertPercent *int `json:"minio_usage_alert_percent"` + P2PConnectTimeoutSec *int `json:"p2p_connect_timeout_sec"` + TURNConnectTimeoutSec *int `json:"turn_connect_timeout_sec"` + MinIOFallbackEnabled *bool `json:"minio_fallback_enabled"` + TURNURLs []string `json:"turn_urls"` + TURNUsername *string `json:"turn_username"` + TURNPassword *string `json:"turn_password"` +} + +func decodeRuntimeConfig(raw []byte, fallback model.RuntimeConfig) (model.RuntimeConfig, error) { + cfg := fallback + if len(raw) == 0 { + return cfg, nil + } + + var payload runtimeConfigPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return model.RuntimeConfig{}, err + } + + if payload.MaxMinIOFallbackSizeBytes != nil { + cfg.MaxMinIOFallbackSizeBytes = *payload.MaxMinIOFallbackSizeBytes + } else if payload.MaxMinIOFallbackGB != nil { + cfg.MaxMinIOFallbackSizeBytes = *payload.MaxMinIOFallbackGB * 1024 * 1024 * 1024 + } + if payload.MinIOCapacityBytes != nil { + cfg.MinIOCapacityBytes = *payload.MinIOCapacityBytes + } else if payload.MinIOCapacityGB != nil { + cfg.MinIOCapacityBytes = *payload.MinIOCapacityGB * 1024 * 1024 * 1024 + } + if payload.MinIORetentionHours != nil { + cfg.MinIORetentionHours = *payload.MinIORetentionHours + } + if payload.MinIOUsageAlertPercent != nil { + cfg.MinIOUsageAlertPercent = *payload.MinIOUsageAlertPercent + } + if payload.P2PConnectTimeoutSec != nil { + cfg.P2PConnectTimeoutSec = *payload.P2PConnectTimeoutSec + } + if payload.TURNConnectTimeoutSec != nil { + cfg.TURNConnectTimeoutSec = *payload.TURNConnectTimeoutSec + } + if payload.MinIOFallbackEnabled != nil { + cfg.MinIOFallbackEnabled = *payload.MinIOFallbackEnabled + } + if payload.TURNURLs != nil { + cfg.TURNURLs = payload.TURNURLs + } + if payload.TURNUsername != nil { + cfg.TURNUsername = *payload.TURNUsername + } + if payload.TURNPassword != nil { + cfg.TURNPassword = *payload.TURNPassword + } + + return cfg, nil +} + +func readFirstExistingFile(paths ...string) ([]byte, error) { + candidates := make([]string, 0, len(paths)*8) + seen := make(map[string]struct{}) + + cwd, err := os.Getwd() + if err == nil { + current := cwd + for { + for _, path := range paths { + candidate := filepath.Clean(filepath.Join(current, path)) + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + candidates = append(candidates, candidate) + } + + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + } + + for _, path := range paths { + candidate := filepath.Clean(path) + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + candidates = append(candidates, candidate) + } + + for _, candidate := range candidates { + data, err := os.ReadFile(candidate) + if err == nil { + return data, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + + return nil, os.ErrNotExist +} diff --git a/backend/internal/storage/sqlite.go b/backend/internal/storage/sqlite.go new file mode 100644 index 0000000..10be082 --- /dev/null +++ b/backend/internal/storage/sqlite.go @@ -0,0 +1,617 @@ +package storage + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "filefast/backend/internal/config" + "filefast/backend/internal/model" + "filefast/backend/internal/store" + + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" +) + +type SQLiteClient struct { + db *sql.DB + path string +} + +func NewSQLiteClient(cfg config.SQLiteConfig) (*SQLiteClient, error) { + path := strings.TrimSpace(cfg.Path) + if path == "" { + return nil, errors.New("sqlite path is required") + } + + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + + client := &SQLiteClient{ + db: db, + path: path, + } + + for _, pragma := range []string{ + "PRAGMA journal_mode = WAL", + "PRAGMA foreign_keys = ON", + "PRAGMA busy_timeout = 5000", + } { + if _, err := db.Exec(pragma); err != nil { + _ = db.Close() + return nil, err + } + } + + return client, nil +} + +func (c *SQLiteClient) Ping(ctx context.Context) error { + return c.db.PingContext(ctx) +} + +func (c *SQLiteClient) EnsureSchema(ctx context.Context) error { + sqlBytes, err := readFirstExistingFile( + filepath.Join("sql", "init_sqlite.sql"), + filepath.Join("backend", "sql", "init_sqlite.sql"), + ) + if err != nil { + return err + } + + _, err = c.db.ExecContext(ctx, string(sqlBytes)) + return err +} + +func (c *SQLiteClient) ResetOnlineDevices(ctx context.Context) error { + _, err := c.db.ExecContext(ctx, `UPDATE devices SET is_online = 0 WHERE is_online = 1`) + return err +} + +func (c *SQLiteClient) ResetOperationalData(ctx context.Context) error { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + for _, statement := range []string{ + `DELETE FROM fallback_objects`, + `DELETE FROM transfers`, + `DELETE FROM sessions`, + `DELETE FROM rooms`, + `DELETE FROM devices`, + } { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return err + } + } + + return tx.Commit() +} + +func (c *SQLiteClient) LoadSnapshot(ctx context.Context, fallbackRuntime model.RuntimeConfig) (store.Snapshot, error) { + snapshot := store.Snapshot{ + Runtime: &fallbackRuntime, + } + + devices, err := c.loadDevices(ctx) + if err != nil { + return store.Snapshot{}, err + } + snapshot.Devices = devices + + rooms, err := c.loadRooms(ctx) + if err != nil { + return store.Snapshot{}, err + } + snapshot.Rooms = rooms + + transfers, err := c.loadTransfers(ctx) + if err != nil { + return store.Snapshot{}, err + } + snapshot.Transfers = transfers + + fallbackObjects, err := c.loadFallbackObjects(ctx) + if err != nil { + return store.Snapshot{}, err + } + snapshot.FallbackObjects = fallbackObjects + + runtimeCfg, err := c.loadRuntimeConfig(ctx, fallbackRuntime) + if err != nil { + return store.Snapshot{}, err + } + snapshot.Runtime = &runtimeCfg + + return snapshot, nil +} + +func (c *SQLiteClient) PersistDevice(ctx context.Context, device model.Device) error { + _, err := c.db.ExecContext(ctx, ` + INSERT INTO devices ( + id, device_code, name, type, user_agent, network_group_key, public_ip_hash, + is_online, last_seen_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + device_code = excluded.device_code, + name = excluded.name, + type = excluded.type, + user_agent = excluded.user_agent, + network_group_key = excluded.network_group_key, + public_ip_hash = excluded.public_ip_hash, + is_online = excluded.is_online, + last_seen_at = excluded.last_seen_at, + updated_at = excluded.updated_at + `, + device.ID, + device.ID, + device.Name, + device.Type, + nullableString(device.UserAgent), + nullableString(device.NetworkGroupKey), + nullableString(device.PublicIPHash), + boolToInt(device.IsOnline), + encodeTime(device.LastSeenAt), + encodeTime(device.CreatedAt), + encodeTime(time.Now()), + ) + return err +} + +func (c *SQLiteClient) PersistRoom(ctx context.Context, room model.Room) error { + _, err := c.db.ExecContext(ctx, ` + INSERT INTO rooms ( + code, creator_device_id, joiner_device_id, status, created_at, expires_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + creator_device_id = excluded.creator_device_id, + joiner_device_id = excluded.joiner_device_id, + status = excluded.status, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + `, + room.Code, + room.CreatorDeviceID, + nullableString(room.JoinerDeviceID), + room.Status, + encodeTime(room.CreatedAt), + encodeTime(room.ExpiresAt), + encodeTime(time.Now()), + ) + return err +} + +func (c *SQLiteClient) PersistTransfer(ctx context.Context, transfer model.Transfer) error { + _, err := c.db.ExecContext(ctx, ` + INSERT INTO transfers ( + id, session_id, kind, name, content, size_bytes, + sender_device_id, receiver_device_id, transfer_strategy, current_channel, + fallback_allowed, final_status, fallback_reason, object_key, expires_at, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + session_id = excluded.session_id, + kind = excluded.kind, + name = excluded.name, + content = excluded.content, + size_bytes = excluded.size_bytes, + sender_device_id = excluded.sender_device_id, + receiver_device_id = excluded.receiver_device_id, + transfer_strategy = excluded.transfer_strategy, + current_channel = excluded.current_channel, + fallback_allowed = excluded.fallback_allowed, + final_status = excluded.final_status, + fallback_reason = excluded.fallback_reason, + object_key = excluded.object_key, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + `, + transfer.ID, + nullableString(transfer.SessionID), + transfer.Kind, + transfer.Name, + nullableString(transfer.Content), + transfer.SizeBytes, + transfer.SenderDeviceID, + transfer.ReceiverDeviceID, + transfer.TransferStrategy, + transfer.CurrentChannel, + boolToInt(transfer.FallbackAllowed), + transfer.FinalStatus, + nullableString(transfer.FallbackReason), + nullableString(transfer.ObjectKey), + nullableTime(transfer.ExpiresAt), + encodeTime(transfer.CreatedAt), + encodeTime(transfer.UpdatedAt), + ) + return err +} + +func (c *SQLiteClient) PersistFallbackObject(ctx context.Context, object model.FallbackObject) error { + _, err := c.db.ExecContext(ctx, ` + INSERT INTO fallback_objects ( + transfer_id, bucket, object_key, size_bytes, cleanup_state, created_at, expires_at, cleaned_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(transfer_id) DO UPDATE SET + bucket = excluded.bucket, + object_key = excluded.object_key, + size_bytes = excluded.size_bytes, + cleanup_state = excluded.cleanup_state, + expires_at = excluded.expires_at, + cleaned_at = excluded.cleaned_at + `, + object.TransferID, + "filefast-fallback", + object.ObjectKey, + object.SizeBytes, + object.CleanupState, + encodeTime(object.CreatedAt), + encodeTime(object.ExpiresAt), + nullableTime(object.CleanedAt), + ) + return err +} + +func (c *SQLiteClient) PersistRuntimeConfig(ctx context.Context, runtime model.RuntimeConfig) error { + payload, err := json.Marshal(runtime) + if err != nil { + return err + } + + _, err = c.db.ExecContext(ctx, ` + INSERT INTO system_configs (config_key, config_value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(config_key) DO UPDATE SET + config_value = excluded.config_value, + updated_at = excluded.updated_at + `, runtimeConfigKey, string(payload), encodeTime(time.Now())) + return err +} + +func (c *SQLiteClient) EnsureAdminUser(ctx context.Context, username, password string) error { + username = strings.TrimSpace(username) + password = strings.TrimSpace(password) + if username == "" || password == "" { + return nil + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + _, err = c.db.ExecContext(ctx, ` + INSERT INTO admin_users (username, password_hash, is_active, created_at, updated_at) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(username) DO NOTHING + `, username, string(hash), encodeTime(time.Now()), encodeTime(time.Now())) + return err +} + +func (c *SQLiteClient) ValidateAdminCredentials(ctx context.Context, username, password string) (bool, error) { + var passwordHash string + var isActive int + err := c.db.QueryRowContext(ctx, ` + SELECT password_hash, is_active + FROM admin_users + WHERE username = ? + `, strings.TrimSpace(username)).Scan(&passwordHash, &isActive) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + if isActive == 0 { + return false, nil + } + + return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(strings.TrimSpace(password))) == nil, nil +} + +func (c *SQLiteClient) Close() error { + return c.db.Close() +} + +func (c *SQLiteClient) loadDevices(ctx context.Context) ([]model.Device, error) { + rows, err := c.db.QueryContext(ctx, ` + SELECT + id, + name, + type, + COALESCE(user_agent, ''), + COALESCE(network_group_key, ''), + COALESCE(public_ip_hash, ''), + is_online, + COALESCE(last_seen_at, created_at), + created_at + FROM devices + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var devices []model.Device + for rows.Next() { + var ( + device model.Device + isOnline int + lastSeen string + createdAt string + ) + if err := rows.Scan( + &device.ID, + &device.Name, + &device.Type, + &device.UserAgent, + &device.NetworkGroupKey, + &device.PublicIPHash, + &isOnline, + &lastSeen, + &createdAt, + ); err != nil { + return nil, err + } + device.IsOnline = isOnline != 0 + device.LastSeenAt, err = decodeTime(lastSeen) + if err != nil { + return nil, err + } + device.CreatedAt, err = decodeTime(createdAt) + if err != nil { + return nil, err + } + devices = append(devices, device) + } + + return devices, rows.Err() +} + +func (c *SQLiteClient) loadRooms(ctx context.Context) ([]model.Room, error) { + rows, err := c.db.QueryContext(ctx, ` + SELECT + code, + creator_device_id, + COALESCE(joiner_device_id, ''), + status, + created_at, + expires_at + FROM rooms + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var rooms []model.Room + for rows.Next() { + var ( + room model.Room + createdAt string + expiresAt string + ) + if err := rows.Scan( + &room.Code, + &room.CreatorDeviceID, + &room.JoinerDeviceID, + &room.Status, + &createdAt, + &expiresAt, + ); err != nil { + return nil, err + } + room.CreatedAt, err = decodeTime(createdAt) + if err != nil { + return nil, err + } + room.ExpiresAt, err = decodeTime(expiresAt) + if err != nil { + return nil, err + } + rooms = append(rooms, room) + } + + return rooms, rows.Err() +} + +func (c *SQLiteClient) loadTransfers(ctx context.Context) ([]model.Transfer, error) { + rows, err := c.db.QueryContext(ctx, ` + SELECT + id, + COALESCE(session_id, ''), + kind, + name, + COALESCE(content, ''), + size_bytes, + sender_device_id, + receiver_device_id, + transfer_strategy, + current_channel, + fallback_allowed, + final_status, + created_at, + updated_at, + COALESCE(fallback_reason, ''), + COALESCE(object_key, ''), + expires_at + FROM transfers + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var transfers []model.Transfer + for rows.Next() { + var ( + transfer model.Transfer + createdAt string + updatedAt string + expiresAt sql.NullString + canFallback int + ) + if err := rows.Scan( + &transfer.ID, + &transfer.SessionID, + &transfer.Kind, + &transfer.Name, + &transfer.Content, + &transfer.SizeBytes, + &transfer.SenderDeviceID, + &transfer.ReceiverDeviceID, + &transfer.TransferStrategy, + &transfer.CurrentChannel, + &canFallback, + &transfer.FinalStatus, + &createdAt, + &updatedAt, + &transfer.FallbackReason, + &transfer.ObjectKey, + &expiresAt, + ); err != nil { + return nil, err + } + transfer.FallbackAllowed = canFallback != 0 + transfer.CreatedAt, err = decodeTime(createdAt) + if err != nil { + return nil, err + } + transfer.UpdatedAt, err = decodeTime(updatedAt) + if err != nil { + return nil, err + } + if expiresAt.Valid && strings.TrimSpace(expiresAt.String) != "" { + parsed, err := decodeTime(expiresAt.String) + if err != nil { + return nil, err + } + transfer.ExpiresAt = &parsed + } + transfers = append(transfers, transfer) + } + + return transfers, rows.Err() +} + +func (c *SQLiteClient) loadFallbackObjects(ctx context.Context) ([]model.FallbackObject, error) { + rows, err := c.db.QueryContext(ctx, ` + SELECT + transfer_id, + object_key, + size_bytes, + created_at, + expires_at, + cleaned_at, + cleanup_state + FROM fallback_objects + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var objects []model.FallbackObject + for rows.Next() { + var ( + object model.FallbackObject + createdAt string + expiresAt string + cleanedAt sql.NullString + ) + if err := rows.Scan( + &object.TransferID, + &object.ObjectKey, + &object.SizeBytes, + &createdAt, + &expiresAt, + &cleanedAt, + &object.CleanupState, + ); err != nil { + return nil, err + } + object.CreatedAt, err = decodeTime(createdAt) + if err != nil { + return nil, err + } + object.ExpiresAt, err = decodeTime(expiresAt) + if err != nil { + return nil, err + } + if cleanedAt.Valid && strings.TrimSpace(cleanedAt.String) != "" { + parsed, err := decodeTime(cleanedAt.String) + if err != nil { + return nil, err + } + object.CleanedAt = &parsed + } + objects = append(objects, object) + } + + return objects, rows.Err() +} + +func (c *SQLiteClient) loadRuntimeConfig(ctx context.Context, fallback model.RuntimeConfig) (model.RuntimeConfig, error) { + var raw string + err := c.db.QueryRowContext(ctx, ` + SELECT config_value + FROM system_configs + WHERE config_key = ? + `, runtimeConfigKey).Scan(&raw) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fallback, nil + } + return model.RuntimeConfig{}, err + } + + return decodeRuntimeConfig([]byte(raw), fallback) +} + +func encodeTime(value time.Time) string { + return value.UTC().Format(time.RFC3339Nano) +} + +func nullableTime(value *time.Time) any { + if value == nil { + return nil + } + return encodeTime(*value) +} + +func decodeTime(value string) (time.Time, error) { + return time.Parse(time.RFC3339Nano, value) +} + +func nullableString(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return value +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go new file mode 100644 index 0000000..1325a77 --- /dev/null +++ b/backend/internal/store/memory.go @@ -0,0 +1,478 @@ +package store + +import ( + "context" + "sort" + "sync" + "time" + + "filefast/backend/internal/model" +) + +type Persistence interface { + PersistDevice(context.Context, model.Device) error + PersistRoom(context.Context, model.Room) error + PersistTransfer(context.Context, model.Transfer) error + PersistFallbackObject(context.Context, model.FallbackObject) error + PersistRuntimeConfig(context.Context, model.RuntimeConfig) error +} + +type Snapshot struct { + Devices []model.Device + Rooms []model.Room + Transfers []model.Transfer + FallbackObjects []model.FallbackObject + Runtime *model.RuntimeConfig +} + +type MemoryStore struct { + mu sync.RWMutex + devices map[string]model.Device + rooms map[string]model.Room + transfers map[string]model.Transfer + fallbackObjects map[string]model.FallbackObject + adminSessions map[string]model.AdminSession + deviceSessions map[string]model.DeviceSession + runtime model.RuntimeConfig + persistence Persistence + persistTimeout time.Duration + onPersistError func(kind, id string, err error) +} + +const ( + activeDeviceWindow = 2 * time.Minute + activeTransferWindow = 30 * time.Minute + recentTerminalTransferWind = 24 * time.Hour +) + +func NewMemoryStore(runtime model.RuntimeConfig) *MemoryStore { + return &MemoryStore{ + devices: make(map[string]model.Device), + rooms: make(map[string]model.Room), + transfers: make(map[string]model.Transfer), + fallbackObjects: make(map[string]model.FallbackObject), + adminSessions: make(map[string]model.AdminSession), + deviceSessions: make(map[string]model.DeviceSession), + runtime: runtime, + } +} + +func (s *MemoryStore) UpsertDevice(device model.Device) model.Device { + s.mu.Lock() + s.devices[device.ID] = device + s.mu.Unlock() + s.persistDevice(device) + return device +} + +func (s *MemoryStore) GetDevice(id string) (model.Device, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + device, ok := s.devices[id] + return device, ok +} + +func (s *MemoryStore) ListDevices() []model.Device { + s.mu.RLock() + defer s.mu.RUnlock() + + devices := make([]model.Device, 0, len(s.devices)) + for _, device := range s.devices { + devices = append(devices, device) + } + + return devices +} + +func (s *MemoryStore) UpsertRoom(room model.Room) model.Room { + s.mu.Lock() + s.rooms[room.Code] = room + s.mu.Unlock() + s.persistRoom(room) + return room +} + +func (s *MemoryStore) GetRoom(code string) (model.Room, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + room, ok := s.rooms[code] + return room, ok +} + +func (s *MemoryStore) UpsertTransfer(transfer model.Transfer) model.Transfer { + s.mu.Lock() + s.transfers[transfer.ID] = transfer + s.mu.Unlock() + s.persistTransfer(transfer) + return transfer +} + +func (s *MemoryStore) GetTransfer(id string) (model.Transfer, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + transfer, ok := s.transfers[id] + return transfer, ok +} + +func (s *MemoryStore) ListRecentTransfers(limit int) []model.Transfer { + s.mu.RLock() + defer s.mu.RUnlock() + + now := time.Now() + transfers := make([]model.Transfer, 0, len(s.transfers)) + for _, transfer := range s.transfers { + if !isTransferVisible(transfer, now) { + continue + } + transfers = append(transfers, transfer) + } + + sort.Slice(transfers, func(i, j int) bool { + return transfers[i].UpdatedAt.After(transfers[j].UpdatedAt) + }) + + if limit > 0 && len(transfers) > limit { + return transfers[:limit] + } + + return transfers +} + +func (s *MemoryStore) ListPendingFallbackDownloads(receiverDeviceID string, limit int) []model.PendingFallbackDownload { + s.mu.RLock() + defer s.mu.RUnlock() + + if receiverDeviceID == "" { + return nil + } + + now := time.Now() + downloads := make([]model.PendingFallbackDownload, 0, len(s.transfers)) + + for _, transfer := range s.transfers { + if transfer.ReceiverDeviceID != receiverDeviceID || transfer.ObjectKey == "" || transfer.FinalStatus != model.TransferCompleted { + continue + } + if transfer.ExpiresAt == nil || !transfer.ExpiresAt.After(now) { + continue + } + + object, ok := s.fallbackObjects[transfer.ID] + if !ok || object.CleanedAt != nil || object.ExpiresAt.Before(now) || object.CleanupState != "ready" { + continue + } + + downloads = append(downloads, model.PendingFallbackDownload{ + TransferID: transfer.ID, + Name: transfer.Name, + SizeBytes: transfer.SizeBytes, + CreatedAt: transfer.CreatedAt, + ExpiresAt: object.ExpiresAt, + DownloadPath: "/api/transfers/" + transfer.ID + "/fallback/download", + SenderDeviceID: transfer.SenderDeviceID, + }) + } + + sort.Slice(downloads, func(i, j int) bool { + return downloads[i].CreatedAt.After(downloads[j].CreatedAt) + }) + + if limit > 0 && len(downloads) > limit { + return downloads[:limit] + } + + return downloads +} + +func (s *MemoryStore) SaveFallbackObject(object model.FallbackObject) model.FallbackObject { + s.mu.Lock() + s.fallbackObjects[object.TransferID] = object + s.mu.Unlock() + s.persistFallbackObject(object) + return object +} + +func (s *MemoryStore) GetFallbackObject(transferID string) (model.FallbackObject, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + object, ok := s.fallbackObjects[transferID] + return object, ok +} + +func (s *MemoryStore) ListExpiredFallbackObjects(now time.Time) []model.FallbackObject { + s.mu.RLock() + defer s.mu.RUnlock() + + var objects []model.FallbackObject + for _, object := range s.fallbackObjects { + if object.CleanedAt == nil && !object.ExpiresAt.After(now) { + objects = append(objects, object) + } + } + + return objects +} + +func (s *MemoryStore) SaveAdminSession(session model.AdminSession) model.AdminSession { + s.mu.Lock() + defer s.mu.Unlock() + s.adminSessions[session.Token] = session + return session +} + +func (s *MemoryStore) SaveDeviceSession(session model.DeviceSession) model.DeviceSession { + s.mu.Lock() + defer s.mu.Unlock() + s.deviceSessions[session.DeviceID] = session + return session +} + +func (s *MemoryStore) HasAdminSession(token string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.adminSessions[token] + return ok +} + +func (s *MemoryStore) ValidateDeviceSession(deviceID, token string) bool { + s.mu.RLock() + session, ok := s.deviceSessions[deviceID] + s.mu.RUnlock() + if !ok { + return false + } + if session.ExpiresAt.Before(time.Now()) { + s.mu.Lock() + delete(s.deviceSessions, deviceID) + s.mu.Unlock() + return false + } + return session.Token == token +} + +func (s *MemoryStore) RuntimeConfig() model.RuntimeConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.runtime +} + +func (s *MemoryStore) UpdateRuntimeConfig(runtime model.RuntimeConfig) model.RuntimeConfig { + s.mu.Lock() + s.runtime = runtime + s.mu.Unlock() + s.persistRuntimeConfig(runtime) + return runtime +} + +func (s *MemoryStore) SnapshotStats() map[string]int { + s.mu.RLock() + defer s.mu.RUnlock() + + now := time.Now() + onlineDevices := 0 + activeDevices := 0 + for _, device := range s.devices { + if isDeviceActive(device, now) { + activeDevices++ + } + if device.IsOnline && isDeviceActive(device, now) { + onlineDevices++ + } + } + + waitingRooms := 0 + for _, room := range s.rooms { + if room.Status == model.RoomStatusWaiting && room.ExpiresAt.After(now) { + waitingRooms++ + } + } + + fallbackPending := 0 + for _, object := range s.fallbackObjects { + if object.CleanedAt == nil && object.CleanupState == "ready" { + fallbackPending++ + } + } + + validTransfers := 0 + for _, transfer := range s.transfers { + if isTransferVisible(transfer, now) { + validTransfers++ + } + } + + return map[string]int{ + "devices_total": activeDevices, + "devices_online": onlineDevices, + "rooms_waiting": waitingRooms, + "transfers_total": validTransfers, + "transfers_cumulative": len(s.transfers), + "fallback_pending": fallbackPending, + "admin_sessions": len(s.adminSessions), + } +} + +func (s *MemoryStore) SnapshotMinIOStorage() model.MinIOStorageOverview { + s.mu.RLock() + defer s.mu.RUnlock() + + now := time.Now() + usedBytes := int64(0) + objectCount := 0 + + for _, object := range s.fallbackObjects { + if object.CleanedAt != nil || !object.ExpiresAt.After(now) || object.CleanupState != "ready" { + continue + } + + usedBytes += object.SizeBytes + objectCount++ + } + + capacityBytes := s.runtime.MinIOCapacityBytes + if capacityBytes < 0 { + capacityBytes = 0 + } + + remainingBytes := capacityBytes - usedBytes + if remainingBytes < 0 { + remainingBytes = 0 + } + + usagePercent := 0 + if capacityBytes > 0 { + usagePercent = int((usedBytes * 100) / capacityBytes) + if usagePercent > 100 { + usagePercent = 100 + } + } + + return model.MinIOStorageOverview{ + Enabled: s.runtime.MinIOFallbackEnabled, + UsedBytes: usedBytes, + CapacityBytes: capacityBytes, + RemainingBytes: remainingBytes, + UsagePercent: usagePercent, + ObjectCount: objectCount, + } +} + +func isDeviceActive(device model.Device, now time.Time) bool { + if device.LastSeenAt.IsZero() { + return false + } + + return device.LastSeenAt.After(now.Add(-activeDeviceWindow)) +} + +func isTransferVisible(transfer model.Transfer, now time.Time) bool { + if transfer.UpdatedAt.IsZero() { + transfer.UpdatedAt = transfer.CreatedAt + } + + if isTerminalTransferStatus(transfer.FinalStatus) { + return transfer.UpdatedAt.After(now.Add(-recentTerminalTransferWind)) + } + + return transfer.UpdatedAt.After(now.Add(-activeTransferWindow)) +} + +func isTerminalTransferStatus(status string) bool { + switch status { + case model.TransferCompleted, model.TransferFailed, model.TransferCancelled: + return true + default: + return false + } +} + +func (s *MemoryStore) SetPersistence(persistence Persistence, timeout time.Duration, onError func(kind, id string, err error)) { + s.mu.Lock() + defer s.mu.Unlock() + + s.persistence = persistence + s.persistTimeout = timeout + s.onPersistError = onError +} + +func (s *MemoryStore) LoadSnapshot(snapshot Snapshot) { + s.mu.Lock() + defer s.mu.Unlock() + + s.devices = make(map[string]model.Device, len(snapshot.Devices)) + for _, device := range snapshot.Devices { + s.devices[device.ID] = device + } + + s.rooms = make(map[string]model.Room, len(snapshot.Rooms)) + for _, room := range snapshot.Rooms { + s.rooms[room.Code] = room + } + + s.transfers = make(map[string]model.Transfer, len(snapshot.Transfers)) + for _, transfer := range snapshot.Transfers { + s.transfers[transfer.ID] = transfer + } + + s.fallbackObjects = make(map[string]model.FallbackObject, len(snapshot.FallbackObjects)) + for _, object := range snapshot.FallbackObjects { + s.fallbackObjects[object.TransferID] = object + } + + if snapshot.Runtime != nil { + s.runtime = *snapshot.Runtime + } +} + +func (s *MemoryStore) persistDevice(device model.Device) { + s.persist(device.ID, "device", func(ctx context.Context, persistence Persistence) error { + return persistence.PersistDevice(ctx, device) + }) +} + +func (s *MemoryStore) persistRoom(room model.Room) { + s.persist(room.Code, "room", func(ctx context.Context, persistence Persistence) error { + return persistence.PersistRoom(ctx, room) + }) +} + +func (s *MemoryStore) persistTransfer(transfer model.Transfer) { + s.persist(transfer.ID, "transfer", func(ctx context.Context, persistence Persistence) error { + return persistence.PersistTransfer(ctx, transfer) + }) +} + +func (s *MemoryStore) persistFallbackObject(object model.FallbackObject) { + s.persist(object.TransferID, "fallback_object", func(ctx context.Context, persistence Persistence) error { + return persistence.PersistFallbackObject(ctx, object) + }) +} + +func (s *MemoryStore) persistRuntimeConfig(runtime model.RuntimeConfig) { + s.persist("transfer_policy", "runtime_config", func(ctx context.Context, persistence Persistence) error { + return persistence.PersistRuntimeConfig(ctx, runtime) + }) +} + +func (s *MemoryStore) persist(id, kind string, fn func(context.Context, Persistence) error) { + s.mu.RLock() + persistence := s.persistence + timeout := s.persistTimeout + onError := s.onPersistError + s.mu.RUnlock() + + if persistence == nil { + return + } + + if timeout <= 0 { + timeout = 5 * time.Second + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := fn(ctx, persistence); err != nil && onError != nil { + onError(kind, id, err) + } +} diff --git a/backend/internal/ws/hub.go b/backend/internal/ws/hub.go new file mode 100644 index 0000000..cb2965a --- /dev/null +++ b/backend/internal/ws/hub.go @@ -0,0 +1,368 @@ +package ws + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "time" + + "filefast/backend/internal/model" + "filefast/backend/internal/service" + "filefast/backend/internal/storage" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 8 * 1024 * 1024 +) + +type Hub struct { + logger *slog.Logger + deviceService *service.DeviceService + backplane realtimeBackplane + instanceID string + clients map[string]*Client + register chan *Client + unregister chan *Client + relay chan relayMessage +} + +type Client struct { + hub *Hub + deviceID string + conn *websocket.Conn + send chan []byte +} + +type relayMessage struct { + targetDeviceID string + payload []byte +} + +type inboundMessage struct { + Type string `json:"type"` + TargetDeviceID string `json:"target_device_id"` + Payload json.RawMessage `json:"payload"` +} + +type backplaneEnvelope struct { + Source string `json:"source"` + TargetDeviceID string `json:"target_device_id,omitempty"` + Payload json.RawMessage `json:"payload"` +} + +type realtimeBackplane interface { + PublishRealtime(context.Context, string, []byte) error + SubscribeRealtime(context.Context, ...string) (<-chan storage.RealtimeMessage, error) +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + + parsed, err := url.Parse(origin) + if err != nil { + return false + } + + return originHostMatchesRequest(parsed.Host, r) + }, +} + +func NewHub(logger *slog.Logger, deviceService *service.DeviceService, backplane realtimeBackplane) *Hub { + return &Hub{ + logger: logger, + deviceService: deviceService, + backplane: backplane, + instanceID: fmt.Sprintf("%d", time.Now().UnixNano()), + clients: make(map[string]*Client), + register: make(chan *Client), + unregister: make(chan *Client), + relay: make(chan relayMessage), + } +} + +func (h *Hub) Run() { + var backplaneMessages <-chan storage.RealtimeMessage + if h.backplane != nil { + messages, err := h.backplane.SubscribeRealtime(context.Background(), storage.RedisRealtimeRelayChannel, storage.RedisRealtimePresenceChannel) + if err != nil { + h.logger.Warn("failed to subscribe redis realtime backplane", "error", err) + } else { + backplaneMessages = messages + } + } + + for { + select { + case client := <-h.register: + h.clients[client.deviceID] = client + h.deviceService.SetOnline(client.deviceID, true) + h.broadcastPresence(client.deviceID, true) + case client := <-h.unregister: + if existing, ok := h.clients[client.deviceID]; ok && existing == client { + delete(h.clients, client.deviceID) + close(client.send) + h.deviceService.SetOnline(client.deviceID, false) + h.broadcastPresence(client.deviceID, false) + } + case message := <-h.relay: + h.dispatchRelay(message.targetDeviceID, message.payload) + case message, ok := <-backplaneMessages: + if !ok { + backplaneMessages = nil + continue + } + h.handleBackplaneMessage(message) + } + } +} + +func (h *Hub) Handle(c *gin.Context) { + deviceID := c.Query("deviceId") + token := strings.TrimSpace(c.Query("deviceToken")) + if deviceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"}) + return + } + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "deviceToken is required"}) + return + } + if !h.deviceService.ValidateSession(deviceID, token) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid device credentials"}) + return + } + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upgrade websocket"}) + return + } + + client := &Client{ + hub: h, + deviceID: deviceID, + conn: conn, + send: make(chan []byte, 32), + } + + h.register <- client + go client.writePump() + client.readPump() +} + +func (h *Hub) broadcastPresence(deviceID string, online bool) { + envelope := model.SignalEnvelope{ + Type: "presence.update", + DeviceID: deviceID, + Payload: map[string]any{ + "online": online, + }, + } + + data, err := json.Marshal(envelope) + if err != nil { + h.logger.Warn("failed to marshal presence update", "error", err) + return + } + + h.broadcastPresencePayload(data) + h.publishBackplane(storage.RedisRealtimePresenceChannel, "", data) +} + +func (h *Hub) broadcastPresencePayload(data []byte) { + for _, client := range h.clients { + client.send <- data + } +} + +func (h *Hub) dispatchRelay(targetDeviceID string, payload []byte) { + if client, ok := h.clients[targetDeviceID]; ok { + client.send <- payload + } + + h.publishBackplane(storage.RedisRealtimeRelayChannel, targetDeviceID, payload) +} + +func (h *Hub) publishBackplane(channel, targetDeviceID string, payload []byte) { + if h.backplane == nil { + return + } + + envelope, err := json.Marshal(backplaneEnvelope{ + Source: h.instanceID, + TargetDeviceID: targetDeviceID, + Payload: payload, + }) + if err != nil { + h.logger.Warn("failed to marshal backplane envelope", "channel", channel, "error", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := h.backplane.PublishRealtime(ctx, channel, envelope); err != nil { + h.logger.Warn("failed to publish realtime backplane message", "channel", channel, "error", err) + } +} + +func (h *Hub) handleBackplaneMessage(message storage.RealtimeMessage) { + var envelope backplaneEnvelope + if err := json.Unmarshal(message.Payload, &envelope); err != nil { + h.logger.Warn("failed to decode realtime backplane message", "channel", message.Channel, "error", err) + return + } + + if envelope.Source == h.instanceID { + return + } + + switch message.Channel { + case storage.RedisRealtimeRelayChannel: + if client, ok := h.clients[envelope.TargetDeviceID]; ok { + client.send <- envelope.Payload + } + case storage.RedisRealtimePresenceChannel: + h.broadcastPresencePayload(envelope.Payload) + } +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + var inbound inboundMessage + if err := c.conn.ReadJSON(&inbound); err != nil { + return + } + + envelope := model.SignalEnvelope{ + Type: inbound.Type, + DeviceID: c.deviceID, + TargetDeviceID: inbound.TargetDeviceID, + Payload: inbound.Payload, + } + + data, err := json.Marshal(envelope) + if err != nil { + continue + } + + c.hub.relay <- relayMessage{ + targetDeviceID: inbound.TargetDeviceID, + payload: data, + } + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func originHostMatchesRequest(originHost string, r *http.Request) bool { + requestHosts := []string{ + strings.TrimSpace(r.Host), + strings.TrimSpace(r.Header.Get("X-Forwarded-Host")), + } + + originName, err := normalizeHost(originHost) + if err != nil { + return false + } + + for _, host := range requestHosts { + if host == "" { + continue + } + + requestName, err := normalizeHost(host) + if err != nil { + continue + } + + if requestName == originName { + return true + } + } + + return false +} + +func normalizeHost(host string) (string, error) { + host = strings.TrimSpace(host) + if host == "" { + return "", fmt.Errorf("empty host") + } + + if strings.Contains(host, "://") { + parsed, err := url.Parse(host) + if err != nil { + return "", err + } + host = parsed.Host + } + + name, _, err := net.SplitHostPort(host) + if err == nil { + host = name + } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + } + + host = strings.ToLower(strings.TrimSpace(host)) + switch host { + case "127.0.0.1", "::1": + return "localhost", nil + default: + return host, nil + } +} diff --git a/backend/server.err.log b/backend/server.err.log new file mode 100644 index 0000000..e69de29 diff --git a/backend/sql/init.sql b/backend/sql/init.sql new file mode 100644 index 0000000..4054079 --- /dev/null +++ b/backend/sql/init.sql @@ -0,0 +1,111 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + type VARCHAR(64) NOT NULL, + user_agent TEXT, + network_group_key VARCHAR(128), + public_ip_hash VARCHAR(128), + is_online BOOLEAN NOT NULL DEFAULT FALSE, + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rooms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(4) NOT NULL UNIQUE, + creator_device_id UUID NOT NULL REFERENCES devices(id), + joiner_device_id UUID REFERENCES devices(id), + status VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + initiator_device_id UUID NOT NULL REFERENCES devices(id), + target_device_id UUID NOT NULL REFERENCES devices(id), + room_id UUID REFERENCES rooms(id), + connect_mode VARCHAR(32) NOT NULL DEFAULT 'p2p', + status VARCHAR(32) NOT NULL DEFAULT 'connecting', + fail_reason TEXT, + connected_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS transfers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID REFERENCES sessions(id), + kind VARCHAR(16) NOT NULL, + name VARCHAR(255) NOT NULL, + content TEXT, + size_bytes BIGINT NOT NULL DEFAULT 0, + sender_device_id UUID NOT NULL REFERENCES devices(id), + receiver_device_id UUID NOT NULL REFERENCES devices(id), + transfer_strategy VARCHAR(32) NOT NULL, + current_channel VARCHAR(32) NOT NULL DEFAULT 'p2p', + fallback_allowed BOOLEAN NOT NULL DEFAULT FALSE, + final_status VARCHAR(32) NOT NULL DEFAULT 'pending', + fallback_reason TEXT, + object_key TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fallback_objects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + transfer_id UUID NOT NULL UNIQUE REFERENCES transfers(id) ON DELETE CASCADE, + bucket VARCHAR(128) NOT NULL, + object_key TEXT NOT NULL UNIQUE, + size_bytes BIGINT NOT NULL DEFAULT 0, + cleanup_state VARCHAR(32) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + cleaned_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS admin_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS system_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + config_key VARCHAR(128) NOT NULL UNIQUE, + config_value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + operator_type VARCHAR(32) NOT NULL, + operator_id UUID, + action VARCHAR(128) NOT NULL, + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_devices_online ON devices (is_online, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_devices_network_group ON devices (network_group_key); +CREATE INDEX IF NOT EXISTS idx_rooms_status_expires ON rooms (status, expires_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_status_created ON sessions (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_transfers_status_created ON transfers (final_status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_transfers_sender_receiver ON transfers (sender_device_id, receiver_device_id); +CREATE INDEX IF NOT EXISTS idx_fallback_objects_expires ON fallback_objects (cleanup_state, expires_at ASC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs (created_at DESC); + +INSERT INTO system_configs (config_key, config_value) +VALUES + ('transfer_policy', '{"max_minio_fallback_size_bytes":10737418240,"minio_capacity_bytes":128849018880,"minio_retention_hours":2,"p2p_connect_timeout_sec":15,"turn_connect_timeout_sec":20,"minio_usage_alert_percent":85,"minio_fallback_enabled":true,"turn_urls":[],"turn_username":"","turn_password":""}'::jsonb) +ON CONFLICT (config_key) DO NOTHING; diff --git a/backend/sql/init_sqlite.sql b/backend/sql/init_sqlite.sql new file mode 100644 index 0000000..cf8479b --- /dev/null +++ b/backend/sql/init_sqlite.sql @@ -0,0 +1,108 @@ +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + device_code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + type TEXT NOT NULL, + user_agent TEXT, + network_group_key TEXT, + public_ip_hash TEXT, + is_online INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS rooms ( + code TEXT PRIMARY KEY, + creator_device_id TEXT NOT NULL REFERENCES devices(id), + joiner_device_id TEXT REFERENCES devices(id), + status TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + initiator_device_id TEXT NOT NULL REFERENCES devices(id), + target_device_id TEXT NOT NULL REFERENCES devices(id), + room_code TEXT REFERENCES rooms(code), + connect_mode TEXT NOT NULL DEFAULT 'p2p', + status TEXT NOT NULL DEFAULT 'connecting', + fail_reason TEXT, + connected_at TEXT, + closed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS transfers ( + id TEXT PRIMARY KEY, + session_id TEXT REFERENCES sessions(id), + kind TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT, + size_bytes INTEGER NOT NULL DEFAULT 0, + sender_device_id TEXT NOT NULL REFERENCES devices(id), + receiver_device_id TEXT NOT NULL REFERENCES devices(id), + transfer_strategy TEXT NOT NULL, + current_channel TEXT NOT NULL DEFAULT 'p2p', + fallback_allowed INTEGER NOT NULL DEFAULT 0, + final_status TEXT NOT NULL DEFAULT 'pending', + fallback_reason TEXT, + object_key TEXT, + expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS fallback_objects ( + transfer_id TEXT PRIMARY KEY REFERENCES transfers(id) ON DELETE CASCADE, + bucket TEXT NOT NULL, + object_key TEXT NOT NULL UNIQUE, + size_bytes INTEGER NOT NULL DEFAULT 0, + cleanup_state TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + cleaned_at TEXT +); + +CREATE TABLE IF NOT EXISTS admin_users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS system_configs ( + config_key TEXT PRIMARY KEY, + config_value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + operator_type TEXT NOT NULL, + operator_id TEXT, + action TEXT NOT NULL, + payload TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_devices_online ON devices (is_online, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_devices_network_group ON devices (network_group_key); +CREATE INDEX IF NOT EXISTS idx_rooms_status_expires ON rooms (status, expires_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_status_created ON sessions (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_transfers_status_created ON transfers (final_status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_transfers_sender_receiver ON transfers (sender_device_id, receiver_device_id); +CREATE INDEX IF NOT EXISTS idx_fallback_objects_expires ON fallback_objects (cleanup_state, expires_at ASC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs (created_at DESC); + +INSERT INTO system_configs (config_key, config_value, updated_at) +VALUES ( + 'transfer_policy', + '{"max_minio_fallback_size_bytes":10737418240,"minio_capacity_bytes":128849018880,"minio_retention_hours":2,"p2p_connect_timeout_sec":15,"turn_connect_timeout_sec":20,"minio_usage_alert_percent":85,"minio_fallback_enabled":true,"turn_urls":[],"turn_username":"","turn_password":""}', + STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now') +) +ON CONFLICT(config_key) DO NOTHING; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..96eeb98 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,7 @@ +.gocache/ +.gomodcache/ +.gopath/ +tmp/ +*.exe +node_modules + diff --git a/frontend/dist/assets/index-BhftK8R5.js b/frontend/dist/assets/index-BhftK8R5.js new file mode 100644 index 0000000..a834bb5 --- /dev/null +++ b/frontend/dist/assets/index-BhftK8R5.js @@ -0,0 +1,17 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&s(l)}).observe(document,{childList:!0,subtree:!0});function n(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(r){if(r.ep)return;r.ep=!0;const i=n(r);fetch(r.href,i)}})();/** +* @vue/shared v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Ds(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const se={},Kt=[],tt=()=>{},ri=()=>!1,Gn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Fs=e=>e.startsWith("onUpdate:"),Ce=Object.assign,Ns=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},zo=Object.prototype.hasOwnProperty,Z=(e,t)=>zo.call(e,t),B=Array.isArray,Wt=e=>bn(e)==="[object Map]",ii=e=>bn(e)==="[object Set]",wr=e=>bn(e)==="[object Date]",z=e=>typeof e=="function",ae=e=>typeof e=="string",qe=e=>typeof e=="symbol",te=e=>e!==null&&typeof e=="object",oi=e=>(te(e)||z(e))&&z(e.then)&&z(e.catch),li=Object.prototype.toString,bn=e=>li.call(e),Ko=e=>bn(e).slice(8,-1),ai=e=>bn(e)==="[object Object]",Us=e=>ae(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,an=Ds(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Jn=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},Wo=/-\w/g,ke=Jn(e=>e.replace(Wo,t=>t.slice(1).toUpperCase())),Vo=/\B([A-Z])/g,It=Jn(e=>e.replace(Vo,"-$1").toLowerCase()),Yn=Jn(e=>e.charAt(0).toUpperCase()+e.slice(1)),fs=Jn(e=>e?`on${Yn(e)}`:""),et=(e,t)=>!Object.is(e,t),Un=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},Bs=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let xr;const Xn=()=>xr||(xr=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Be(e){if(B(e)){const t={};for(let n=0;n{if(n){const s=n.split(Go);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function xt(e){let t="";if(ae(e))t=e;else if(B(e))for(let n=0;n!!(e&&e.__v_isRef===!0),ie=e=>ae(e)?e:e==null?"":B(e)||te(e)&&(e.toString===li||!z(e.toString))?fi(e)?ie(e.value):JSON.stringify(e,di,2):String(e),di=(e,t)=>fi(t)?di(e,t.value):Wt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],i)=>(n[ds(s,i)+" =>"]=r,n),{})}:ii(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>ds(n))}:qe(t)?ds(t):te(t)&&!B(t)&&!ai(t)?String(t):t,ds=(e,t="")=>{var n;return qe(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Oe;class el{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=Oe,!t&&Oe&&(this.index=(Oe.scopes||(Oe.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0&&(Oe=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(un){let t=un;for(un=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;cn;){let t=cn;for(cn=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function gi(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function vi(e){let t,n=e.depsTail,s=n;for(;s;){const r=s.prevDep;s.version===-1?(s===n&&(n=r),zs(s),nl(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=r}e.deps=t,e.depsTail=n}function Ss(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(yi(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function yi(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===mn)||(e.globalVersion=mn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ss(e))))return;e.flags|=2;const t=e.dep,n=re,s=Ve;re=e,Ve=!0;try{gi(e);const r=e.fn(e._value);(t.version===0||et(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{re=n,Ve=s,vi(e),e.flags&=-3}}function zs(e,t=!1){const{dep:n,prevSub:s,nextSub:r}=e;if(s&&(s.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let i=n.computed.deps;i;i=i.nextDep)zs(i,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function nl(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let Ve=!0;const _i=[];function ft(){_i.push(Ve),Ve=!1}function dt(){const e=_i.pop();Ve=e===void 0?!0:e}function Ir(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=re;re=void 0;try{t()}finally{re=n}}}let mn=0;class sl{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Ks{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!re||!Ve||re===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==re)n=this.activeLink=new sl(re,this),re.deps?(n.prevDep=re.depsTail,re.depsTail.nextDep=n,re.depsTail=n):re.deps=re.depsTail=n,bi(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=re.depsTail,n.nextDep=void 0,re.depsTail.nextDep=n,re.depsTail=n,re.deps===n&&(re.deps=s)}return n}trigger(t){this.version++,mn++,this.notify(t)}notify(t){js();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{Hs()}}}function bi(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)bi(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const Ts=new WeakMap,Ot=Symbol(""),Cs=Symbol(""),gn=Symbol("");function Ie(e,t,n){if(Ve&&re){let s=Ts.get(e);s||Ts.set(e,s=new Map);let r=s.get(n);r||(s.set(n,r=new Ks),r.map=s,r.key=n),r.track()}}function ct(e,t,n,s,r,i){const l=Ts.get(e);if(!l){mn++;return}const a=f=>{f&&f.trigger()};if(js(),t==="clear")l.forEach(a);else{const f=B(e),m=f&&Us(n);if(f&&n==="length"){const p=Number(s);l.forEach((_,C)=>{(C==="length"||C===gn||!qe(C)&&C>=p)&&a(_)})}else switch((n!==void 0||l.has(void 0))&&a(l.get(n)),m&&a(l.get(gn)),t){case"add":f?m&&a(l.get("length")):(a(l.get(Ot)),Wt(e)&&a(l.get(Cs)));break;case"delete":f||(a(l.get(Ot)),Wt(e)&&a(l.get(Cs)));break;case"set":Wt(e)&&a(l.get(Ot));break}}Hs()}function Lt(e){const t=X(e);return t===e?t:(Ie(t,"iterate",gn),Le(e)?t:t.map(Ge))}function Zn(e){return Ie(e=X(e),"iterate",gn),e}function Ze(e,t){return pt(e)?Yt(Pt(e)?Ge(t):t):Ge(t)}const rl={__proto__:null,[Symbol.iterator](){return hs(this,Symbol.iterator,e=>Ze(this,e))},concat(...e){return Lt(this).concat(...e.map(t=>B(t)?Lt(t):t))},entries(){return hs(this,"entries",e=>(e[1]=Ze(this,e[1]),e))},every(e,t){return ot(this,"every",e,t,void 0,arguments)},filter(e,t){return ot(this,"filter",e,t,n=>n.map(s=>Ze(this,s)),arguments)},find(e,t){return ot(this,"find",e,t,n=>Ze(this,n),arguments)},findIndex(e,t){return ot(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return ot(this,"findLast",e,t,n=>Ze(this,n),arguments)},findLastIndex(e,t){return ot(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return ot(this,"forEach",e,t,void 0,arguments)},includes(...e){return ms(this,"includes",e)},indexOf(...e){return ms(this,"indexOf",e)},join(e){return Lt(this).join(e)},lastIndexOf(...e){return ms(this,"lastIndexOf",e)},map(e,t){return ot(this,"map",e,t,void 0,arguments)},pop(){return sn(this,"pop")},push(...e){return sn(this,"push",e)},reduce(e,...t){return Sr(this,"reduce",e,t)},reduceRight(e,...t){return Sr(this,"reduceRight",e,t)},shift(){return sn(this,"shift")},some(e,t){return ot(this,"some",e,t,void 0,arguments)},splice(...e){return sn(this,"splice",e)},toReversed(){return Lt(this).toReversed()},toSorted(e){return Lt(this).toSorted(e)},toSpliced(...e){return Lt(this).toSpliced(...e)},unshift(...e){return sn(this,"unshift",e)},values(){return hs(this,"values",e=>Ze(this,e))}};function hs(e,t,n){const s=Zn(e),r=s[t]();return s!==e&&!Le(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=n(i.value)),i}),r}const il=Array.prototype;function ot(e,t,n,s,r,i){const l=Zn(e),a=l!==e&&!Le(e),f=l[t];if(f!==il[t]){const _=f.apply(e,i);return a?Ge(_):_}let m=n;l!==e&&(a?m=function(_,C){return n.call(this,Ze(e,_),C,e)}:n.length>2&&(m=function(_,C){return n.call(this,_,C,e)}));const p=f.call(l,m,s);return a&&r?r(p):p}function Sr(e,t,n,s){const r=Zn(e),i=r!==e&&!Le(e);let l=n,a=!1;r!==e&&(i?(a=s.length===0,l=function(m,p,_){return a&&(a=!1,m=Ze(e,m)),n.call(this,m,Ze(e,p),_,e)}):n.length>3&&(l=function(m,p,_){return n.call(this,m,p,_,e)}));const f=r[t](l,...s);return a?Ze(e,f):f}function ms(e,t,n){const s=X(e);Ie(s,"iterate",gn);const r=s[t](...n);return(r===-1||r===!1)&&Gs(n[0])?(n[0]=X(n[0]),s[t](...n)):r}function sn(e,t,n=[]){ft(),js();const s=X(e)[t].apply(e,n);return Hs(),dt(),s}const ol=Ds("__proto__,__v_isRef,__isVue"),wi=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(qe));function ll(e){qe(e)||(e=String(e));const t=X(this);return Ie(t,"has",e),t.hasOwnProperty(e)}class xi{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(n==="__v_isReactive")return!r;if(n==="__v_isReadonly")return r;if(n==="__v_isShallow")return i;if(n==="__v_raw")return s===(r?i?vl:Ci:i?Ti:Si).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const l=B(t);if(!r){let f;if(l&&(f=rl[n]))return f;if(n==="hasOwnProperty")return ll}const a=Reflect.get(t,n,Te(t)?t:s);if((qe(n)?wi.has(n):ol(n))||(r||Ie(t,"get",n),i))return a;if(Te(a)){const f=l&&Us(n)?a:a.value;return r&&te(f)?Ms(f):f}return te(a)?r?Ms(a):Vs(a):a}}class Ii extends xi{constructor(t=!1){super(!1,t)}set(t,n,s,r){let i=t[n];const l=B(t)&&Us(n);if(!this._isShallow){const m=pt(i);if(!Le(s)&&!pt(s)&&(i=X(i),s=X(s)),!l&&Te(i)&&!Te(s))return m||(i.value=s),!0}const a=l?Number(n)e,Mn=e=>Reflect.getPrototypeOf(e);function dl(e,t,n){return function(...s){const r=this.__v_raw,i=X(r),l=Wt(i),a=e==="entries"||e===Symbol.iterator&&l,f=e==="keys"&&l,m=r[e](...s),p=n?$s:t?Yt:Ge;return!t&&Ie(i,"iterate",f?Cs:Ot),Ce(Object.create(m),{next(){const{value:_,done:C}=m.next();return C?{value:_,done:C}:{value:a?[p(_[0]),p(_[1])]:p(_),done:C}}})}}function Rn(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function pl(e,t){const n={get(r){const i=this.__v_raw,l=X(i),a=X(r);e||(et(r,a)&&Ie(l,"get",r),Ie(l,"get",a));const{has:f}=Mn(l),m=t?$s:e?Yt:Ge;if(f.call(l,r))return m(i.get(r));if(f.call(l,a))return m(i.get(a));i!==l&&i.get(r)},get size(){const r=this.__v_raw;return!e&&Ie(X(r),"iterate",Ot),r.size},has(r){const i=this.__v_raw,l=X(i),a=X(r);return e||(et(r,a)&&Ie(l,"has",r),Ie(l,"has",a)),r===a?i.has(r):i.has(r)||i.has(a)},forEach(r,i){const l=this,a=l.__v_raw,f=X(a),m=t?$s:e?Yt:Ge;return!e&&Ie(f,"iterate",Ot),a.forEach((p,_)=>r.call(i,m(p),m(_),l))}};return Ce(n,e?{add:Rn("add"),set:Rn("set"),delete:Rn("delete"),clear:Rn("clear")}:{add(r){const i=X(this),l=Mn(i),a=X(r),f=!t&&!Le(r)&&!pt(r)?a:r;return l.has.call(i,f)||et(r,f)&&l.has.call(i,r)||et(a,f)&&l.has.call(i,a)||(i.add(f),ct(i,"add",f,f)),this},set(r,i){!t&&!Le(i)&&!pt(i)&&(i=X(i));const l=X(this),{has:a,get:f}=Mn(l);let m=a.call(l,r);m||(r=X(r),m=a.call(l,r));const p=f.call(l,r);return l.set(r,i),m?et(i,p)&&ct(l,"set",r,i):ct(l,"add",r,i),this},delete(r){const i=X(this),{has:l,get:a}=Mn(i);let f=l.call(i,r);f||(r=X(r),f=l.call(i,r)),a&&a.call(i,r);const m=i.delete(r);return f&&ct(i,"delete",r,void 0),m},clear(){const r=X(this),i=r.size!==0,l=r.clear();return i&&ct(r,"clear",void 0,void 0),l}}),["keys","values","entries",Symbol.iterator].forEach(r=>{n[r]=dl(r,e,t)}),n}function Ws(e,t){const n=pl(e,t);return(s,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?s:Reflect.get(Z(n,r)&&r in s?n:s,r,i)}const hl={get:Ws(!1,!1)},ml={get:Ws(!1,!0)},gl={get:Ws(!0,!1)};const Si=new WeakMap,Ti=new WeakMap,Ci=new WeakMap,vl=new WeakMap;function yl(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function _l(e){return e.__v_skip||!Object.isExtensible(e)?0:yl(Ko(e))}function Vs(e){return pt(e)?e:qs(e,!1,cl,hl,Si)}function bl(e){return qs(e,!1,fl,ml,Ti)}function Ms(e){return qs(e,!0,ul,gl,Ci)}function qs(e,t,n,s,r){if(!te(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const i=_l(e);if(i===0)return e;const l=r.get(e);if(l)return l;const a=new Proxy(e,i===2?s:n);return r.set(e,a),a}function Pt(e){return pt(e)?Pt(e.__v_raw):!!(e&&e.__v_isReactive)}function pt(e){return!!(e&&e.__v_isReadonly)}function Le(e){return!!(e&&e.__v_isShallow)}function Gs(e){return e?!!e.__v_raw:!1}function X(e){const t=e&&e.__v_raw;return t?X(t):e}function wl(e){return!Z(e,"__v_skip")&&Object.isExtensible(e)&&ci(e,"__v_skip",!0),e}const Ge=e=>te(e)?Vs(e):e,Yt=e=>te(e)?Ms(e):e;function Te(e){return e?e.__v_isRef===!0:!1}function oe(e){return xl(e,!1)}function xl(e,t){return Te(e)?e:new Il(e,t)}class Il{constructor(t,n){this.dep=new Ks,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:X(t),this._value=n?t:Ge(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||Le(t)||pt(t);t=s?t:X(t),et(t,n)&&(this._rawValue=t,this._value=s?t:Ge(t),this.dep.trigger())}}function Sl(e){return Te(e)?e.value:e}const Tl={get:(e,t,n)=>t==="__v_raw"?e:Sl(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const r=e[t];return Te(r)&&!Te(n)?(r.value=n,!0):Reflect.set(e,t,n,s)}};function $i(e){return Pt(e)?e:new Proxy(e,Tl)}class Cl{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new Ks(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=mn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&re!==this)return mi(this,!0),!0}get value(){const t=this.dep.track();return yi(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function $l(e,t,n=!1){let s,r;return z(e)?s=e:(s=e.get,r=e.set),new Cl(s,r,n)}const An={},Hn=new WeakMap;let Et;function Ml(e,t=!1,n=Et){if(n){let s=Hn.get(n);s||Hn.set(n,s=[]),s.push(e)}}function Rl(e,t,n=se){const{immediate:s,deep:r,once:i,scheduler:l,augmentJob:a,call:f}=n,m=F=>r?F:Le(F)||r===!1||r===0?ut(F,1):ut(F);let p,_,C,S,U=!1,D=!1;if(Te(e)?(_=()=>e.value,U=Le(e)):Pt(e)?(_=()=>m(e),U=!0):B(e)?(D=!0,U=e.some(F=>Pt(F)||Le(F)),_=()=>e.map(F=>{if(Te(F))return F.value;if(Pt(F))return m(F);if(z(F))return f?f(F,2):F()})):z(e)?t?_=f?()=>f(e,2):e:_=()=>{if(C){ft();try{C()}finally{dt()}}const F=Et;Et=p;try{return f?f(e,3,[S]):e(S)}finally{Et=F}}:_=tt,t&&r){const F=_,le=r===!0?1/0:r;_=()=>ut(F(),le)}const Q=tl(),K=()=>{p.stop(),Q&&Q.active&&Ns(Q.effects,p)};if(i&&t){const F=t;t=(...le)=>{F(...le),K()}}let A=D?new Array(e.length).fill(An):An;const ee=F=>{if(!(!(p.flags&1)||!p.dirty&&!F))if(t){const le=p.run();if(r||U||(D?le.some((he,$e)=>et(he,A[$e])):et(le,A))){C&&C();const he=Et;Et=p;try{const $e=[le,A===An?void 0:D&&A[0]===An?[]:A,S];A=le,f?f(t,3,$e):t(...$e)}finally{Et=he}}}else p.run()};return a&&a(ee),p=new pi(_),p.scheduler=l?()=>l(ee,!1):ee,S=F=>Ml(F,!1,p),C=p.onStop=()=>{const F=Hn.get(p);if(F){if(f)f(F,4);else for(const le of F)le();Hn.delete(p)}},t?s?ee(!0):A=p.run():l?l(ee.bind(null,!0),!0):p.run(),K.pause=p.pause.bind(p),K.resume=p.resume.bind(p),K.stop=K,K}function ut(e,t=1/0,n){if(t<=0||!te(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,Te(e))ut(e.value,t,n);else if(B(e))for(let s=0;s{ut(s,t,n)});else if(ai(e)){for(const s in e)ut(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&ut(e[s],t,n)}return e}/** +* @vue/runtime-core v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function wn(e,t,n,s){try{return s?e(...s):e()}catch(r){Qn(r,t,n)}}function nt(e,t,n,s){if(z(e)){const r=wn(e,t,n,s);return r&&oi(r)&&r.catch(i=>{Qn(i,t,n)}),r}if(B(e)){const r=[];for(let i=0;i>>1,r=Ae[s],i=vn(r);i=vn(n)?Ae.push(e):Ae.splice(kl(t),0,e),e.flags|=1,Ai()}}function Ai(){zn||(zn=Mi.then(Ei))}function El(e){B(e)?Vt.push(...e):wt&&e.id===-1?wt.splice(Ht+1,0,e):e.flags&1||(Vt.push(e),e.flags|=1),Ai()}function Tr(e,t,n=Xe+1){for(;nvn(n)-vn(s));if(Vt.length=0,wt){wt.push(...t);return}for(wt=t,Ht=0;Hte.id==null?e.flags&2?-1:1/0:e.id;function Ei(e){try{for(Xe=0;Xe{s._d&&Nr(-1);const i=Kn(t);let l;try{l=e(...r)}finally{Kn(i),s._d&&Nr(1)}return l};return s._n=!0,s._c=!0,s._d=!0,s}function Ol(e,t){if(ye===null)return e;const n=ss(ye),s=e.dirs||(e.dirs=[]);for(let r=0;r1)return n&&z(t)?t.call(s&&s.proxy):t}}const Dl=Symbol.for("v-scx"),Fl=()=>Bn(Dl);function Dt(e,t,n){return Pi(e,t,n)}function Pi(e,t,n=se){const{immediate:s,deep:r,flush:i,once:l}=n,a=Ce({},n),f=t&&s||!t&&i!=="post";let m;if(_n){if(i==="sync"){const S=Fl();m=S.__watcherHandles||(S.__watcherHandles=[])}else if(!f){const S=()=>{};return S.stop=tt,S.resume=tt,S.pause=tt,S}}const p=Se;a.call=(S,U,D)=>nt(S,p,U,D);let _=!1;i==="post"?a.scheduler=S=>{Ee(S,p&&p.suspense)}:i!=="sync"&&(_=!0,a.scheduler=(S,U)=>{U?S():Js(S)}),a.augmentJob=S=>{t&&(S.flags|=4),_&&(S.flags|=2,p&&(S.id=p.uid,S.i=p))};const C=Rl(e,t,a);return _n&&(m?m.push(C):f&&C()),C}function Nl(e,t,n){const s=this.proxy,r=ae(e)?e.includes(".")?Di(s,e):()=>s[e]:e.bind(s,s);let i;z(t)?i=t:(i=t.handler,n=t);const l=In(this),a=Pi(r,i.bind(s),n);return l(),a}function Di(e,t){const n=t.split(".");return()=>{let s=e;for(let r=0;re.__isTeleport,Ll=Symbol("_leaveCb");function Ys(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Ys(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Fi(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Cr(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const Wn=new WeakMap;function fn(e,t,n,s,r=!1){if(B(e)){e.forEach((D,Q)=>fn(D,t&&(B(t)?t[Q]:t),n,s,r));return}if(Gt(s)&&!r){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&fn(e,t,n,s.component.subTree);return}const i=s.shapeFlag&4?ss(s.component):s.el,l=r?null:i,{i:a,r:f}=e,m=t&&t.r,p=a.refs===se?a.refs={}:a.refs,_=a.setupState,C=X(_),S=_===se?ri:D=>Cr(p,D)?!1:Z(C,D),U=(D,Q)=>!(Q&&Cr(p,Q));if(m!=null&&m!==f){if($r(t),ae(m))p[m]=null,S(m)&&(_[m]=null);else if(Te(m)){const D=t;U(m,D.k)&&(m.value=null),D.k&&(p[D.k]=null)}}if(z(f))wn(f,a,12,[l,p]);else{const D=ae(f),Q=Te(f);if(D||Q){const K=()=>{if(e.f){const A=D?S(f)?_[f]:p[f]:U()||!e.k?f.value:p[e.k];if(r)B(A)&&Ns(A,i);else if(B(A))A.includes(i)||A.push(i);else if(D)p[f]=[i],S(f)&&(_[f]=p[f]);else{const ee=[i];U(f,e.k)&&(f.value=ee),e.k&&(p[e.k]=ee)}}else D?(p[f]=l,S(f)&&(_[f]=l)):Q&&(U(f,e.k)&&(f.value=l),e.k&&(p[e.k]=l))};if(l){const A=()=>{K(),Wn.delete(e)};A.id=-1,Wn.set(e,A),Ee(A,n)}else $r(e),K()}}}function $r(e){const t=Wn.get(e);t&&(t.flags|=8,Wn.delete(e))}Xn().requestIdleCallback;Xn().cancelIdleCallback;const Gt=e=>!!e.type.__asyncLoader,Ni=e=>e.type.__isKeepAlive;function jl(e,t){Ui(e,"a",t)}function Hl(e,t){Ui(e,"da",t)}function Ui(e,t,n=Se){const s=e.__wdc||(e.__wdc=()=>{let r=n;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(es(t,s,n),n){let r=n.parent;for(;r&&r.parent;)Ni(r.parent.vnode)&&zl(s,t,n,r),r=r.parent}}function zl(e,t,n,s){const r=es(t,e,s,!0);Li(()=>{Ns(s[t],r)},n)}function es(e,t,n=Se,s=!1){if(n){const r=n[e]||(n[e]=[]),i=t.__weh||(t.__weh=(...l)=>{ft();const a=In(n),f=nt(t,n,e,l);return a(),dt(),f});return s?r.unshift(i):r.push(i),i}}const gt=e=>(t,n=Se)=>{(!_n||e==="sp")&&es(e,(...s)=>t(...s),n)},Kl=gt("bm"),Bi=gt("m"),Wl=gt("bu"),Vl=gt("u"),Xs=gt("bum"),Li=gt("um"),ql=gt("sp"),Gl=gt("rtg"),Jl=gt("rtc");function Yl(e,t=Se){es("ec",e,t)}const Xl="components",ji=Symbol.for("v-ndc");function Zl(e){return ae(e)?Ql(Xl,e,!1)||e:e||ji}function Ql(e,t,n=!0,s=!1){const r=ye||Se;if(r){const i=r.type;{const a=Ua(i,!1);if(a&&(a===t||a===ke(t)||a===Yn(ke(t))))return i}const l=Mr(r[e]||i[e],t)||Mr(r.appContext[e],t);return!l&&s?i:l}}function Mr(e,t){return e&&(e[t]||e[ke(t)]||e[Yn(ke(t))])}function Xt(e,t,n,s){let r;const i=n,l=B(e);if(l||ae(e)){const a=l&&Pt(e);let f=!1,m=!1;a&&(f=!Le(e),m=pt(e),e=Zn(e)),r=new Array(e.length);for(let p=0,_=e.length;p<_;p++)r[p]=t(f?m?Yt(Ge(e[p])):Ge(e[p]):e[p],p,void 0,i)}else if(typeof e=="number"){r=new Array(e);for(let a=0;at(a,f,void 0,i));else{const a=Object.keys(e);r=new Array(a.length);for(let f=0,m=a.length;f0;return L(),mt(fe,null,[q("slot",n,s)],m?-2:64)}let i=e[t];i&&i._c&&(i._d=!1),L();const l=i&&Hi(i(n)),a=n.key||l&&l.key,f=mt(fe,{key:(a&&!qe(a)?a:`_${t}`)+(!l&&s?"_fb":"")},l||[],l&&e._===1?64:-2);return f.scopeId&&(f.slotScopeIds=[f.scopeId+"-s"]),i&&i._c&&(i._d=!0),f}function Hi(e){return e.some(t=>er(t)?!(t.type===ht||t.type===fe&&!Hi(t.children)):!0)?e:null}const Rs=e=>e?co(e)?ss(e):Rs(e.parent):null,dn=Ce(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Rs(e.parent),$root:e=>Rs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Ki(e),$forceUpdate:e=>e.f||(e.f=()=>{Js(e.update)}),$nextTick:e=>e.n||(e.n=Ri.bind(e.proxy)),$watch:e=>Nl.bind(e)}),gs=(e,t)=>e!==se&&!e.__isScriptSetup&&Z(e,t),ta={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:r,props:i,accessCache:l,type:a,appContext:f}=e;if(t[0]!=="$"){const C=l[t];if(C!==void 0)switch(C){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return i[t]}else{if(gs(s,t))return l[t]=1,s[t];if(r!==se&&Z(r,t))return l[t]=2,r[t];if(Z(i,t))return l[t]=3,i[t];if(n!==se&&Z(n,t))return l[t]=4,n[t];As&&(l[t]=0)}}const m=dn[t];let p,_;if(m)return t==="$attrs"&&Ie(e.attrs,"get",""),m(e);if((p=a.__cssModules)&&(p=p[t]))return p;if(n!==se&&Z(n,t))return l[t]=4,n[t];if(_=f.config.globalProperties,Z(_,t))return _[t]},set({_:e},t,n){const{data:s,setupState:r,ctx:i}=e;return gs(r,t)?(r[t]=n,!0):s!==se&&Z(s,t)?(s[t]=n,!0):Z(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,props:i,type:l}},a){let f;return!!(n[a]||e!==se&&a[0]!=="$"&&Z(e,a)||gs(t,a)||Z(i,a)||Z(s,a)||Z(dn,a)||Z(r.config.globalProperties,a)||(f=l.__cssModules)&&f[a])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:Z(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Rr(e){return B(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let As=!0;function na(e){const t=Ki(e),n=e.proxy,s=e.ctx;As=!1,t.beforeCreate&&Ar(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:l,watch:a,provide:f,inject:m,created:p,beforeMount:_,mounted:C,beforeUpdate:S,updated:U,activated:D,deactivated:Q,beforeDestroy:K,beforeUnmount:A,destroyed:ee,unmounted:F,render:le,renderTracked:he,renderTriggered:$e,errorCaptured:Me,serverPrefetch:vt,expose:je,inheritAttrs:Je,components:He,directives:yt,filters:de}=t;if(m&&sa(m,s,null),l)for(const j in l){const G=l[j];z(G)&&(s[j]=G.bind(n))}if(r){const j=r.call(n,n);te(j)&&(e.data=Vs(j))}if(As=!0,i)for(const j in i){const G=i[j],_e=z(G)?G.bind(n,n):z(G.get)?G.get.bind(n,n):tt,ze=!z(G)&&z(G.set)?G.set.bind(n):tt,be=Ft({get:_e,set:ze});Object.defineProperty(s,j,{enumerable:!0,configurable:!0,get:()=>be.value,set:ce=>be.value=ce})}if(a)for(const j in a)zi(a[j],s,n,j);if(f){const j=z(f)?f.call(n):f;Reflect.ownKeys(j).forEach(G=>{Pl(G,j[G])})}p&&Ar(p,e,"c");function H(j,G){B(G)?G.forEach(_e=>j(_e.bind(n))):G&&j(G.bind(n))}if(H(Kl,_),H(Bi,C),H(Wl,S),H(Vl,U),H(jl,D),H(Hl,Q),H(Yl,Me),H(Jl,he),H(Gl,$e),H(Xs,A),H(Li,F),H(ql,vt),B(je))if(je.length){const j=e.exposed||(e.exposed={});je.forEach(G=>{Object.defineProperty(j,G,{get:()=>n[G],set:_e=>n[G]=_e,enumerable:!0})})}else e.exposed||(e.exposed={});le&&e.render===tt&&(e.render=le),Je!=null&&(e.inheritAttrs=Je),He&&(e.components=He),yt&&(e.directives=yt),vt&&Fi(e)}function sa(e,t,n=tt){B(e)&&(e=ks(e));for(const s in e){const r=e[s];let i;te(r)?"default"in r?i=Bn(r.from||s,r.default,!0):i=Bn(r.from||s):i=Bn(r),Te(i)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>i.value,set:l=>i.value=l}):t[s]=i}}function Ar(e,t,n){nt(B(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function zi(e,t,n,s){let r=s.includes(".")?Di(n,s):()=>n[s];if(ae(e)){const i=t[e];z(i)&&Dt(r,i)}else if(z(e))Dt(r,e.bind(n));else if(te(e))if(B(e))e.forEach(i=>zi(i,t,n,s));else{const i=z(e.handler)?e.handler.bind(n):t[e.handler];z(i)&&Dt(r,i,e)}}function Ki(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:l}}=e.appContext,a=i.get(t);let f;return a?f=a:!r.length&&!n&&!s?f=t:(f={},r.length&&r.forEach(m=>Vn(f,m,l,!0)),Vn(f,t,l)),te(t)&&i.set(t,f),f}function Vn(e,t,n,s=!1){const{mixins:r,extends:i}=t;i&&Vn(e,i,n,!0),r&&r.forEach(l=>Vn(e,l,n,!0));for(const l in t)if(!(s&&l==="expose")){const a=ra[l]||n&&n[l];e[l]=a?a(e[l],t[l]):t[l]}return e}const ra={data:kr,props:Er,emits:Er,methods:on,computed:on,beforeCreate:Re,created:Re,beforeMount:Re,mounted:Re,beforeUpdate:Re,updated:Re,beforeDestroy:Re,beforeUnmount:Re,destroyed:Re,unmounted:Re,activated:Re,deactivated:Re,errorCaptured:Re,serverPrefetch:Re,components:on,directives:on,watch:oa,provide:kr,inject:ia};function kr(e,t){return t?e?function(){return Ce(z(e)?e.call(this,this):e,z(t)?t.call(this,this):t)}:t:e}function ia(e,t){return on(ks(e),ks(t))}function ks(e){if(B(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${ke(t)}Modifiers`]||e[`${It(t)}Modifiers`];function ua(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||se;let r=n;const i=t.startsWith("update:"),l=i&&ca(s,t.slice(7));l&&(l.trim&&(r=n.map(p=>ae(p)?p.trim():p)),l.number&&(r=n.map(Bs)));let a,f=s[a=fs(t)]||s[a=fs(ke(t))];!f&&i&&(f=s[a=fs(It(t))]),f&&nt(f,e,6,r);const m=s[a+"Once"];if(m){if(!e.emitted)e.emitted={};else if(e.emitted[a])return;e.emitted[a]=!0,nt(m,e,6,r)}}const fa=new WeakMap;function Vi(e,t,n=!1){const s=n?fa:t.emitsCache,r=s.get(e);if(r!==void 0)return r;const i=e.emits;let l={},a=!1;if(!z(e)){const f=m=>{const p=Vi(m,t,!0);p&&(a=!0,Ce(l,p))};!n&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}return!i&&!a?(te(e)&&s.set(e,null),null):(B(i)?i.forEach(f=>l[f]=null):Ce(l,i),te(e)&&s.set(e,l),l)}function ts(e,t){return!e||!Gn(t)?!1:(t=t.slice(2).replace(/Once$/,""),Z(e,t[0].toLowerCase()+t.slice(1))||Z(e,It(t))||Z(e,t))}function Or(e){const{type:t,vnode:n,proxy:s,withProxy:r,propsOptions:[i],slots:l,attrs:a,emit:f,render:m,renderCache:p,props:_,data:C,setupState:S,ctx:U,inheritAttrs:D}=e,Q=Kn(e);let K,A;try{if(n.shapeFlag&4){const F=r||s,le=F;K=Qe(m.call(le,F,p,_,S,C,U)),A=a}else{const F=t;K=Qe(F.length>1?F(_,{attrs:a,slots:l,emit:f}):F(_,null)),A=t.props?a:da(a)}}catch(F){pn.length=0,Qn(F,e,1),K=q(ht)}let ee=K;if(A&&D!==!1){const F=Object.keys(A),{shapeFlag:le}=ee;F.length&&le&7&&(i&&F.some(Fs)&&(A=pa(A,i)),ee=Zt(ee,A,!1,!0))}return n.dirs&&(ee=Zt(ee,null,!1,!0),ee.dirs=ee.dirs?ee.dirs.concat(n.dirs):n.dirs),n.transition&&Ys(ee,n.transition),K=ee,Kn(Q),K}const da=e=>{let t;for(const n in e)(n==="class"||n==="style"||Gn(n))&&((t||(t={}))[n]=e[n]);return t},pa=(e,t)=>{const n={};for(const s in e)(!Fs(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function ha(e,t,n){const{props:s,children:r,component:i}=e,{props:l,children:a,patchFlag:f}=t,m=i.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&f>=0){if(f&1024)return!0;if(f&16)return s?Pr(s,l,m):!!l;if(f&8){const p=t.dynamicProps;for(let _=0;_Object.create(Gi),Yi=e=>Object.getPrototypeOf(e)===Gi;function ga(e,t,n,s=!1){const r={},i=Ji();e.propsDefaults=Object.create(null),Xi(e,t,r,i);for(const l in e.propsOptions[0])l in r||(r[l]=void 0);n?e.props=s?r:bl(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function va(e,t,n,s){const{props:r,attrs:i,vnode:{patchFlag:l}}=e,a=X(r),[f]=e.propsOptions;let m=!1;if((s||l>0)&&!(l&16)){if(l&8){const p=e.vnode.dynamicProps;for(let _=0;_{f=!0;const[C,S]=Zi(_,t,!0);Ce(l,C),S&&a.push(...S)};!n&&t.mixins.length&&t.mixins.forEach(p),e.extends&&p(e.extends),e.mixins&&e.mixins.forEach(p)}if(!i&&!f)return te(e)&&s.set(e,Kt),Kt;if(B(i))for(let p=0;pe==="_"||e==="_ctx"||e==="$stable",Qs=e=>B(e)?e.map(Qe):[Qe(e)],_a=(e,t,n)=>{if(t._n)return t;const s=qt((...r)=>Qs(t(...r)),n);return s._c=!1,s},Qi=(e,t,n)=>{const s=e._ctx;for(const r in e){if(Zs(r))continue;const i=e[r];if(z(i))t[r]=_a(r,i,s);else if(i!=null){const l=Qs(i);t[r]=()=>l}}},eo=(e,t)=>{const n=Qs(t);e.slots.default=()=>n},to=(e,t,n)=>{for(const s in t)(n||!Zs(s))&&(e[s]=t[s])},ba=(e,t,n)=>{const s=e.slots=Ji();if(e.vnode.shapeFlag&32){const r=t._;r?(to(s,t,n),n&&ci(s,"_",r,!0)):Qi(t,s)}else t&&eo(e,t)},wa=(e,t,n)=>{const{vnode:s,slots:r}=e;let i=!0,l=se;if(s.shapeFlag&32){const a=t._;a?n&&a===1?i=!1:to(r,t,n):(i=!t.$stable,Qi(t,r)),l=t}else t&&(eo(e,t),l={default:1});if(i)for(const a in r)!Zs(a)&&l[a]==null&&delete r[a]},Ee=Ca;function xa(e){return Ia(e)}function Ia(e,t){const n=Xn();n.__VUE__=!0;const{insert:s,remove:r,patchProp:i,createElement:l,createText:a,createComment:f,setText:m,setElementText:p,parentNode:_,nextSibling:C,setScopeId:S=tt,insertStaticContent:U}=e,D=(d,h,v,I=null,w=null,x=null,R=void 0,$=null,T=!!h.dynamicChildren)=>{if(d===h)return;d&&!rn(d,h)&&(I=Nt(d),ce(d,w,x,!0),d=null),h.patchFlag===-2&&(T=!1,h.dynamicChildren=null);const{type:b,ref:P,shapeFlag:k}=h;switch(b){case ns:Q(d,h,v,I);break;case ht:K(d,h,v,I);break;case ys:d==null&&A(h,v,I,R);break;case fe:He(d,h,v,I,w,x,R,$,T);break;default:k&1?le(d,h,v,I,w,x,R,$,T):k&6?yt(d,h,v,I,w,x,R,$,T):(k&64||k&128)&&b.process(d,h,v,I,w,x,R,$,T,rt)}P!=null&&w?fn(P,d&&d.ref,x,h||d,!h):P==null&&d&&d.ref!=null&&fn(d.ref,null,x,d,!0)},Q=(d,h,v,I)=>{if(d==null)s(h.el=a(h.children),v,I);else{const w=h.el=d.el;h.children!==d.children&&m(w,h.children)}},K=(d,h,v,I)=>{d==null?s(h.el=f(h.children||""),v,I):h.el=d.el},A=(d,h,v,I)=>{[d.el,d.anchor]=U(d.children,h,v,I,d.el,d.anchor)},ee=({el:d,anchor:h},v,I)=>{let w;for(;d&&d!==h;)w=C(d),s(d,v,I),d=w;s(h,v,I)},F=({el:d,anchor:h})=>{let v;for(;d&&d!==h;)v=C(d),r(d),d=v;r(h)},le=(d,h,v,I,w,x,R,$,T)=>{if(h.type==="svg"?R="svg":h.type==="math"&&(R="mathml"),d==null)he(h,v,I,w,x,R,$,T);else{const b=d.el&&d.el._isVueCE?d.el:null;try{b&&b._beginPatch(),vt(d,h,w,x,R,$,T)}finally{b&&b._endPatch()}}},he=(d,h,v,I,w,x,R,$)=>{let T,b;const{props:P,shapeFlag:k,transition:E,dirs:N}=d;if(T=d.el=l(d.type,x,P&&P.is,P),k&8?p(T,d.children):k&16&&Me(d.children,T,null,I,w,vs(d,x),R,$),N&&At(d,null,I,"created"),$e(T,d,d.scopeId,R,I),P){for(const Y in P)Y!=="value"&&!an(Y)&&i(T,Y,null,P[Y],x,I);"value"in P&&i(T,"value",null,P.value,x),(b=P.onVnodeBeforeMount)&&Ye(b,I,d)}N&&At(d,null,I,"beforeMount");const W=Sa(w,E);W&&E.beforeEnter(T),s(T,h,v),((b=P&&P.onVnodeMounted)||W||N)&&Ee(()=>{b&&Ye(b,I,d),W&&E.enter(T),N&&At(d,null,I,"mounted")},w)},$e=(d,h,v,I,w)=>{if(v&&S(d,v),I)for(let x=0;x{for(let b=T;b{const $=h.el=d.el;let{patchFlag:T,dynamicChildren:b,dirs:P}=h;T|=d.patchFlag&16;const k=d.props||se,E=h.props||se;let N;if(v&&kt(v,!1),(N=E.onVnodeBeforeUpdate)&&Ye(N,v,h,d),P&&At(h,d,v,"beforeUpdate"),v&&kt(v,!0),(k.innerHTML&&E.innerHTML==null||k.textContent&&E.textContent==null)&&p($,""),b?je(d.dynamicChildren,b,$,v,I,vs(h,w),x):R||G(d,h,$,null,v,I,vs(h,w),x,!1),T>0){if(T&16)Je($,k,E,v,w);else if(T&2&&k.class!==E.class&&i($,"class",null,E.class,w),T&4&&i($,"style",k.style,E.style,w),T&8){const W=h.dynamicProps;for(let Y=0;Y{N&&Ye(N,v,h,d),P&&At(h,d,v,"updated")},I)},je=(d,h,v,I,w,x,R)=>{for(let $=0;${if(h!==v){if(h!==se)for(const x in h)!an(x)&&!(x in v)&&i(d,x,h[x],null,w,I);for(const x in v){if(an(x))continue;const R=v[x],$=h[x];R!==$&&x!=="value"&&i(d,x,$,R,w,I)}"value"in v&&i(d,"value",h.value,v.value,w)}},He=(d,h,v,I,w,x,R,$,T)=>{const b=h.el=d?d.el:a(""),P=h.anchor=d?d.anchor:a("");let{patchFlag:k,dynamicChildren:E,slotScopeIds:N}=h;N&&($=$?$.concat(N):N),d==null?(s(b,v,I),s(P,v,I),Me(h.children||[],v,P,w,x,R,$,T)):k>0&&k&64&&E&&d.dynamicChildren&&d.dynamicChildren.length===E.length?(je(d.dynamicChildren,E,v,w,x,R,$),(h.key!=null||w&&h===w.subTree)&&no(d,h,!0)):G(d,h,v,P,w,x,R,$,T)},yt=(d,h,v,I,w,x,R,$,T)=>{h.slotScopeIds=$,d==null?h.shapeFlag&512?w.ctx.activate(h,v,I,R,T):de(h,v,I,w,x,R,T):st(d,h,T)},de=(d,h,v,I,w,x,R)=>{const $=d.component=Ea(d,I,w);if(Ni(d)&&($.ctx.renderer=rt),Pa($,!1,R),$.asyncDep){if(w&&w.registerDep($,H,R),!d.el){const T=$.subTree=q(ht);K(null,T,h,v),d.placeholder=T.el}}else H($,d,h,v,w,x,R)},st=(d,h,v)=>{const I=h.component=d.component;if(ha(d,h,v))if(I.asyncDep&&!I.asyncResolved){j(I,h,v);return}else I.next=h,I.update();else h.el=d.el,I.vnode=h},H=(d,h,v,I,w,x,R)=>{const $=()=>{if(d.isMounted){let{next:k,bu:E,u:N,parent:W,vnode:Y}=d;{const Ne=so(d);if(Ne){k&&(k.el=Y.el,j(d,k,R)),Ne.asyncDep.then(()=>{Ee(()=>{d.isUnmounted||b()},w)});return}}let J=k,we;kt(d,!1),k?(k.el=Y.el,j(d,k,R)):k=Y,E&&Un(E),(we=k.props&&k.props.onVnodeBeforeUpdate)&&Ye(we,W,k,Y),kt(d,!0);const xe=Or(d),Fe=d.subTree;d.subTree=xe,D(Fe,xe,_(Fe.el),Nt(Fe),d,w,x),k.el=xe.el,J===null&&ma(d,xe.el),N&&Ee(N,w),(we=k.props&&k.props.onVnodeUpdated)&&Ee(()=>Ye(we,W,k,Y),w)}else{let k;const{el:E,props:N}=h,{bm:W,m:Y,parent:J,root:we,type:xe}=d,Fe=Gt(h);kt(d,!1),W&&Un(W),!Fe&&(k=N&&N.onVnodeBeforeMount)&&Ye(k,J,h),kt(d,!0);{we.ce&&we.ce._hasShadowRoot()&&we.ce._injectChildStyle(xe,d.parent?d.parent.type:void 0);const Ne=d.subTree=Or(d);D(null,Ne,v,I,d,w,x),h.el=Ne.el}if(Y&&Ee(Y,w),!Fe&&(k=N&&N.onVnodeMounted)){const Ne=h;Ee(()=>Ye(k,J,Ne),w)}(h.shapeFlag&256||J&&Gt(J.vnode)&&J.vnode.shapeFlag&256)&&d.a&&Ee(d.a,w),d.isMounted=!0,h=v=I=null}};d.scope.on();const T=d.effect=new pi($);d.scope.off();const b=d.update=T.run.bind(T),P=d.job=T.runIfDirty.bind(T);P.i=d,P.id=d.uid,T.scheduler=()=>Js(P),kt(d,!0),b()},j=(d,h,v)=>{h.component=d;const I=d.vnode.props;d.vnode=h,d.next=null,va(d,h.props,I,v),wa(d,h.children,v),ft(),Tr(d),dt()},G=(d,h,v,I,w,x,R,$,T=!1)=>{const b=d&&d.children,P=d?d.shapeFlag:0,k=h.children,{patchFlag:E,shapeFlag:N}=h;if(E>0){if(E&128){ze(b,k,v,I,w,x,R,$,T);return}else if(E&256){_e(b,k,v,I,w,x,R,$,T);return}}N&8?(P&16&&Tt(b,w,x),k!==b&&p(v,k)):P&16?N&16?ze(b,k,v,I,w,x,R,$,T):Tt(b,w,x,!0):(P&8&&p(v,""),N&16&&Me(k,v,I,w,x,R,$,T))},_e=(d,h,v,I,w,x,R,$,T)=>{d=d||Kt,h=h||Kt;const b=d.length,P=h.length,k=Math.min(b,P);let E;for(E=0;EP?Tt(d,w,x,!0,!1,k):Me(h,v,I,w,x,R,$,T,k)},ze=(d,h,v,I,w,x,R,$,T)=>{let b=0;const P=h.length;let k=d.length-1,E=P-1;for(;b<=k&&b<=E;){const N=d[b],W=h[b]=T?at(h[b]):Qe(h[b]);if(rn(N,W))D(N,W,v,null,w,x,R,$,T);else break;b++}for(;b<=k&&b<=E;){const N=d[k],W=h[E]=T?at(h[E]):Qe(h[E]);if(rn(N,W))D(N,W,v,null,w,x,R,$,T);else break;k--,E--}if(b>k){if(b<=E){const N=E+1,W=NE)for(;b<=k;)ce(d[b],w,x,!0),b++;else{const N=b,W=b,Y=new Map;for(b=W;b<=E;b++){const me=h[b]=T?at(h[b]):Qe(h[b]);me.key!=null&&Y.set(me.key,b)}let J,we=0;const xe=E-W+1;let Fe=!1,Ne=0;const Ct=new Array(xe);for(b=0;b=xe){ce(me,w,x,!0);continue}let Pe;if(me.key!=null)Pe=Y.get(me.key);else for(J=W;J<=E;J++)if(Ct[J-W]===0&&rn(me,h[J])){Pe=J;break}Pe===void 0?ce(me,w,x,!0):(Ct[Pe-W]=b+1,Pe>=Ne?Ne=Pe:Fe=!0,D(me,h[Pe],v,null,w,x,R,$,T),we++)}const en=Fe?Ta(Ct):Kt;for(J=en.length-1,b=xe-1;b>=0;b--){const me=W+b,Pe=h[me],tn=h[me+1],nn=me+1{const{el:x,type:R,transition:$,children:T,shapeFlag:b}=d;if(b&6){be(d.component.subTree,h,v,I);return}if(b&128){d.suspense.move(h,v,I);return}if(b&64){R.move(d,h,v,rt);return}if(R===fe){s(x,h,v);for(let k=0;k$.enter(x),w);else{const{leave:k,delayLeave:E,afterLeave:N}=$,W=()=>{d.ctx.isUnmounted?r(x):s(x,h,v)},Y=()=>{x._isLeaving&&x[Ll](!0),k(x,()=>{W(),N&&N()})};E?E(x,W,Y):Y()}else s(x,h,v)},ce=(d,h,v,I=!1,w=!1)=>{const{type:x,props:R,ref:$,children:T,dynamicChildren:b,shapeFlag:P,patchFlag:k,dirs:E,cacheIndex:N}=d;if(k===-2&&(w=!1),$!=null&&(ft(),fn($,null,v,d,!0),dt()),N!=null&&(h.renderCache[N]=void 0),P&256){h.ctx.deactivate(d);return}const W=P&1&&E,Y=!Gt(d);let J;if(Y&&(J=R&&R.onVnodeBeforeUnmount)&&Ye(J,h,d),P&6)Sn(d.component,v,I);else{if(P&128){d.suspense.unmount(v,I);return}W&&At(d,null,h,"beforeUnmount"),P&64?d.type.remove(d,h,v,rt,I):b&&!b.hasOnce&&(x!==fe||k>0&&k&64)?Tt(b,h,v,!1,!0):(x===fe&&k&384||!w&&P&16)&&Tt(T,h,v),I&&_t(d)}(Y&&(J=R&&R.onVnodeUnmounted)||W)&&Ee(()=>{J&&Ye(J,h,d),W&&At(d,null,h,"unmounted")},v)},_t=d=>{const{type:h,el:v,anchor:I,transition:w}=d;if(h===fe){St(v,I);return}if(h===ys){F(d);return}const x=()=>{r(v),w&&!w.persisted&&w.afterLeave&&w.afterLeave()};if(d.shapeFlag&1&&w&&!w.persisted){const{leave:R,delayLeave:$}=w,T=()=>R(v,x);$?$(d.el,x,T):T()}else x()},St=(d,h)=>{let v;for(;d!==h;)v=C(d),r(d),d=v;r(h)},Sn=(d,h,v)=>{const{bum:I,scope:w,job:x,subTree:R,um:$,m:T,a:b}=d;Fr(T),Fr(b),I&&Un(I),w.stop(),x&&(x.flags|=8,ce(R,d,h,v)),$&&Ee($,h),Ee(()=>{d.isUnmounted=!0},h)},Tt=(d,h,v,I=!1,w=!1,x=0)=>{for(let R=x;R{if(d.shapeFlag&6)return Nt(d.component.subTree);if(d.shapeFlag&128)return d.suspense.next();const h=C(d.anchor||d.el),v=h&&h[Ul];return v?C(v):h};let Qt=!1;const Tn=(d,h,v)=>{let I;d==null?h._vnode&&(ce(h._vnode,null,null,!0),I=h._vnode.component):D(h._vnode||null,d,h,null,null,null,v),h._vnode=d,Qt||(Qt=!0,Tr(I),ki(),Qt=!1)},rt={p:D,um:ce,m:be,r:_t,mt:de,mc:Me,pc:G,pbc:je,n:Nt,o:e};return{render:Tn,hydrate:void 0,createApp:aa(Tn)}}function vs({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function kt({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Sa(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function no(e,t,n=!1){const s=e.children,r=t.children;if(B(s)&&B(r))for(let i=0;i>1,e[n[a]]0&&(t[s]=n[i-1]),n[i]=s)}}for(i=n.length,l=n[i-1];i-- >0;)n[i]=l,l=t[l];return n}function so(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:so(t)}function Fr(e){if(e)for(let t=0;te.__isSuspense;function Ca(e,t){t&&t.pendingBranch?B(e)?t.effects.push(...e):t.effects.push(e):El(e)}const fe=Symbol.for("v-fgt"),ns=Symbol.for("v-txt"),ht=Symbol.for("v-cmt"),ys=Symbol.for("v-stc"),pn=[];let De=null;function L(e=!1){pn.push(De=e?null:[])}function $a(){pn.pop(),De=pn[pn.length-1]||null}let yn=1;function Nr(e,t=!1){yn+=e,e<0&&De&&t&&(De.hasOnce=!0)}function oo(e){return e.dynamicChildren=yn>0?De||Kt:null,$a(),yn>0&&De&&De.push(e),e}function V(e,t,n,s,r,i){return oo(y(e,t,n,s,r,i,!0))}function mt(e,t,n,s,r){return oo(q(e,t,n,s,r,!0))}function er(e){return e?e.__v_isVNode===!0:!1}function rn(e,t){return e.type===t.type&&e.key===t.key}const lo=({key:e})=>e??null,Ln=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?ae(e)||Te(e)||z(e)?{i:ye,r:e,k:t,f:!!n}:e:null);function y(e,t=null,n=null,s=0,r=null,i=e===fe?0:1,l=!1,a=!1){const f={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&lo(t),ref:t&&Ln(t),scopeId:Oi,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:s,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:ye};return a?(tr(f,n),i&128&&e.normalize(f)):n&&(f.shapeFlag|=ae(n)?8:16),yn>0&&!l&&De&&(f.patchFlag>0||i&6)&&f.patchFlag!==32&&De.push(f),f}const q=Ma;function Ma(e,t=null,n=null,s=0,r=null,i=!1){if((!e||e===ji)&&(e=ht),er(e)){const a=Zt(e,t,!0);return n&&tr(a,n),yn>0&&!i&&De&&(a.shapeFlag&6?De[De.indexOf(e)]=a:De.push(a)),a.patchFlag=-2,a}if(Ba(e)&&(e=e.__vccOpts),t){t=Ra(t);let{class:a,style:f}=t;a&&!ae(a)&&(t.class=xt(a)),te(f)&&(Gs(f)&&!B(f)&&(f=Ce({},f)),t.style=Be(f))}const l=ae(e)?1:io(e)?128:Bl(e)?64:te(e)?4:z(e)?2:0;return y(e,t,n,s,r,l,i,!0)}function Ra(e){return e?Gs(e)||Yi(e)?Ce({},e):e:null}function Zt(e,t,n=!1,s=!1){const{props:r,ref:i,patchFlag:l,children:a,transition:f}=e,m=t?ao(r||{},t):r,p={__v_isVNode:!0,__v_skip:!0,type:e.type,props:m,key:m&&lo(m),ref:t&&t.ref?n&&i?B(i)?i.concat(Ln(t)):[i,Ln(t)]:Ln(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:a,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==fe?l===-1?16:l|16:l,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:f,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Zt(e.ssContent),ssFallback:e.ssFallback&&Zt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return f&&s&&Ys(p,f.clone(p)),p}function xn(e=" ",t=0){return q(ns,null,e,t)}function We(e="",t=!1){return t?(L(),mt(ht,null,e)):q(ht,null,e)}function Qe(e){return e==null||typeof e=="boolean"?q(ht):B(e)?q(fe,null,e.slice()):er(e)?at(e):q(ns,null,String(e))}function at(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Zt(e)}function tr(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(B(t))n=16;else if(typeof t=="object")if(s&65){const r=t.default;r&&(r._c&&(r._d=!1),tr(e,r()),r._c&&(r._d=!0));return}else{n=32;const r=t._;!r&&!Yi(t)?t._ctx=ye:r===3&&ye&&(ye.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else z(t)?(t={default:t,_ctx:ye},n=32):(t=String(t),s&64?(n=16,t=[xn(t)]):n=8);e.children=t,e.shapeFlag|=n}function ao(...e){const t={};for(let n=0;nSe||ye;let qn,Os;{const e=Xn(),t=(n,s)=>{let r;return(r=e[n])||(r=e[n]=[]),r.push(s),i=>{r.length>1?r.forEach(l=>l(i)):r[0](i)}};qn=t("__VUE_INSTANCE_SETTERS__",n=>Se=n),Os=t("__VUE_SSR_SETTERS__",n=>_n=n)}const In=e=>{const t=Se;return qn(e),e.scope.on(),()=>{e.scope.off(),qn(t)}},Ur=()=>{Se&&Se.scope.off(),qn(null)};function co(e){return e.vnode.shapeFlag&4}let _n=!1;function Pa(e,t=!1,n=!1){t&&Os(t);const{props:s,children:r}=e.vnode,i=co(e);ga(e,s,i,t),ba(e,r,n||t);const l=i?Da(e,t):void 0;return t&&Os(!1),l}function Da(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,ta);const{setup:s}=n;if(s){ft();const r=e.setupContext=s.length>1?Na(e):null,i=In(e),l=wn(s,e,0,[e.props,r]),a=oi(l);if(dt(),i(),(a||e.sp)&&!Gt(e)&&Fi(e),a){if(l.then(Ur,Ur),t)return l.then(f=>{Br(e,f)}).catch(f=>{Qn(f,e,0)});e.asyncDep=l}else Br(e,l)}else uo(e)}function Br(e,t,n){z(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:te(t)&&(e.setupState=$i(t)),uo(e)}function uo(e,t,n){const s=e.type;e.render||(e.render=s.render||tt);{const r=In(e);ft();try{na(e)}finally{dt(),r()}}}const Fa={get(e,t){return Ie(e,"get",""),e[t]}};function Na(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,Fa),slots:e.slots,emit:e.emit,expose:t}}function ss(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy($i(wl(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in dn)return dn[n](e)},has(t,n){return n in t||n in dn}})):e.proxy}function Ua(e,t=!0){return z(e)?e.displayName||e.name:e.name||t&&e.__name}function Ba(e){return z(e)&&"__vccOpts"in e}const Ft=(e,t)=>$l(e,t,_n),La="3.5.30";/** +* @vue/runtime-dom v3.5.30 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Ps;const Lr=typeof window<"u"&&window.trustedTypes;if(Lr)try{Ps=Lr.createPolicy("vue",{createHTML:e=>e})}catch{}const fo=Ps?e=>Ps.createHTML(e):e=>e,ja="http://www.w3.org/2000/svg",Ha="http://www.w3.org/1998/Math/MathML",lt=typeof document<"u"?document:null,jr=lt&<.createElement("template"),za={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const r=t==="svg"?lt.createElementNS(ja,e):t==="mathml"?lt.createElementNS(Ha,e):n?lt.createElement(e,{is:n}):lt.createElement(e);return e==="select"&&s&&s.multiple!=null&&r.setAttribute("multiple",s.multiple),r},createText:e=>lt.createTextNode(e),createComment:e=>lt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>lt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,r,i){const l=n?n.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),n),!(r===i||!(r=r.nextSibling)););else{jr.innerHTML=fo(s==="svg"?`${e}`:s==="mathml"?`${e}`:e);const a=jr.content;if(s==="svg"||s==="mathml"){const f=a.firstChild;for(;f.firstChild;)a.appendChild(f.firstChild);a.removeChild(f)}t.insertBefore(a,n)}return[l?l.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Ka=Symbol("_vtc");function Wa(e,t,n){const s=e[Ka];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Hr=Symbol("_vod"),Va=Symbol("_vsh"),qa=Symbol(""),Ga=/(?:^|;)\s*display\s*:/;function Ja(e,t,n){const s=e.style,r=ae(n);let i=!1;if(n&&!r){if(t)if(ae(t))for(const l of t.split(";")){const a=l.slice(0,l.indexOf(":")).trim();n[a]==null&&jn(s,a,"")}else for(const l in t)n[l]==null&&jn(s,l,"");for(const l in n)l==="display"&&(i=!0),jn(s,l,n[l])}else if(r){if(t!==n){const l=s[qa];l&&(n+=";"+l),s.cssText=n,i=Ga.test(n)}}else t&&e.removeAttribute("style");Hr in e&&(e[Hr]=i?s.display:"",e[Va]&&(s.display="none"))}const zr=/\s*!important$/;function jn(e,t,n){if(B(n))n.forEach(s=>jn(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=Ya(e,t);zr.test(n)?e.setProperty(It(s),n.replace(zr,""),"important"):e[s]=n}}const Kr=["Webkit","Moz","ms"],_s={};function Ya(e,t){const n=_s[t];if(n)return n;let s=ke(t);if(s!=="filter"&&s in e)return _s[t]=s;s=Yn(s);for(let r=0;rbs||(ec.then(()=>bs=0),bs=Date.now());function nc(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;nt(sc(s,n.value),t,5,[s])};return n.value=e,n.attached=tc(),n}function sc(e,t){if(B(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(s=>r=>!r._stopped&&s&&s(r))}else return t}const Yr=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,rc=(e,t,n,s,r,i)=>{const l=r==="svg";t==="class"?Wa(e,s,l):t==="style"?Ja(e,n,s):Gn(t)?Fs(t)||Za(e,t,n,s,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):ic(e,t,s,l))?(qr(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Vr(e,t,s,l,i,t!=="value")):e._isVueCE&&(oc(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ae(s)))?qr(e,ke(t),s,i,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),Vr(e,t,s,l))};function ic(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&Yr(t)&&z(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Yr(t)&&ae(n)?!1:t in e}function oc(e,t){const n=e._def.props;if(!n)return!1;const s=ke(t);return Array.isArray(n)?n.some(r=>ke(r)===s):Object.keys(n).some(r=>ke(r)===s)}const Xr=e=>{const t=e.props["onUpdate:modelValue"]||!1;return B(t)?n=>Un(t,n):t};function lc(e){e.target.composing=!0}function Zr(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ws=Symbol("_assign");function Qr(e,t,n){return t&&(e=e.trim()),n&&(e=Bs(e)),e}const ac={created(e,{modifiers:{lazy:t,trim:n,number:s}},r){e[ws]=Xr(r);const i=s||r.props&&r.props.type==="number";zt(e,t?"change":"input",l=>{l.target.composing||e[ws](Qr(e.value,n,i))}),(n||i)&&zt(e,"change",()=>{e.value=Qr(e.value,n,i)}),t||(zt(e,"compositionstart",lc),zt(e,"compositionend",Zr),zt(e,"change",Zr))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:r,number:i}},l){if(e[ws]=Xr(l),e.composing)return;const a=(i||e.type==="number")&&!/^0\d/.test(e.value)?Bs(e.value):e.value,f=t??"";a!==f&&(document.activeElement===e&&e.type!=="range"&&(s&&t===n||r&&e.value.trim()===f)||(e.value=f))}},cc=["ctrl","shift","alt","meta"],uc={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>cc.some(n=>e[`${n}Key`]&&!t.includes(n))},kn=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=(r,...i)=>{for(let l=0;l{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=r=>{if(!("key"in r))return;const i=It(r.key);if(t.some(l=>l===i||fc[l]===i))return e(r)})},dc=Ce({patchProp:rc},za);let ei;function pc(){return ei||(ei=xa(dc))}const hc=(...e)=>{const t=pc().createApp(...e),{mount:n}=t;return t.mount=s=>{const r=gc(s);if(!r)return;const i=t._component;!z(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const l=n(r,!1,mc(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),l},t};function mc(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function gc(e){return ae(e)?document.querySelector(e):e}const vc={class:"footer"},yc={__name:"AppFooter",emits:["request-admin"],setup(e,{emit:t}){const n=t,s=oe(0);let r=null;Xs(()=>{r&&window.clearTimeout(r)});function i(){s.value+=1,s.value===1&&(r=window.setTimeout(()=>{s.value=0,r=null},2e3)),s.value>=5&&(r&&(window.clearTimeout(r),r=null),s.value=0,n("request-admin"))}return(l,a)=>(L(),V("div",vc,[y("div",null,[a[0]||(a[0]=xn(" © 2026 AirShare Pro. All rights reserved. ",-1)),a[1]||(a[1]=y("span",{class:"divider-line"},"|",-1)),y("span",{id:"admin-trigger",title:"点击 5 次进入后台",onClick:i},"V 1.0.0")]),a[2]||(a[2]=y("div",{style:{"font-size":"12px","margin-top":"4px"}},[y("a",{href:"https://beian.miit.gov.cn/",target:"_blank",rel:"noreferrer"}," 粤ICP备2026888888号-1 ")],-1))]))}},_c=["fill","stroke"],pe={__name:"LocalIcon",props:{name:{type:String,required:!0},size:{type:[Number,String],default:24}},setup(e){const t=e,n={light_mode:{type:"stroke",shapes:[{tag:"circle",attrs:{cx:"12",cy:"12",r:"4"}},{tag:"path",attrs:{d:"M12 2v2.2"}},{tag:"path",attrs:{d:"M12 19.8V22"}},{tag:"path",attrs:{d:"M4.93 4.93 6.5 6.5"}},{tag:"path",attrs:{d:"m17.5 17.5 1.57 1.57"}},{tag:"path",attrs:{d:"M2 12h2.2"}},{tag:"path",attrs:{d:"M19.8 12H22"}},{tag:"path",attrs:{d:"m4.93 19.07 1.57-1.57"}},{tag:"path",attrs:{d:"M17.5 6.5 19.07 4.93"}}]},dark_mode:{type:"fill",shapes:[{tag:"path",attrs:{d:"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"}}]},add_circle:{type:"stroke",shapes:[{tag:"circle",attrs:{cx:"12",cy:"12",r:"9"}},{tag:"path",attrs:{d:"M12 8v8"}},{tag:"path",attrs:{d:"M8 12h8"}}]},sensors:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M12 12h.01"}},{tag:"path",attrs:{d:"M9.2 14.8a4 4 0 0 1 0-5.6"}},{tag:"path",attrs:{d:"M14.8 9.2a4 4 0 0 1 0 5.6"}},{tag:"path",attrs:{d:"M6.4 17.6a8 8 0 0 1 0-11.2"}},{tag:"path",attrs:{d:"M17.6 6.4a8 8 0 0 1 0 11.2"}}]},smartphone:{type:"stroke",shapes:[{tag:"rect",attrs:{x:"7",y:"2.5",width:"10",height:"19",rx:"2.5"}},{tag:"path",attrs:{d:"M10 5h4"}},{tag:"circle",attrs:{cx:"12",cy:"18",r:"0.8"}}]},laptop_mac:{type:"stroke",shapes:[{tag:"rect",attrs:{x:"5",y:"4",width:"14",height:"10",rx:"1.5"}},{tag:"path",attrs:{d:"M3 18h18"}},{tag:"path",attrs:{d:"M8 18a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2"}}]},close:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M6 6l12 12"}},{tag:"path",attrs:{d:"M18 6 6 18"}}]},cloud_upload:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M7 18a4 4 0 1 1 .7-7.94A5.5 5.5 0 0 1 18 11a3.5 3.5 0 1 1-.5 7"}},{tag:"path",attrs:{d:"M12 10v8"}},{tag:"path",attrs:{d:"m8.8 13.2 3.2-3.2 3.2 3.2"}}]},arrow_upward:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M12 19V6"}},{tag:"path",attrs:{d:"m6.5 11.5 5.5-5.5 5.5 5.5"}}]},send_and_archive:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M3 6h18l-2 4H5Z"}},{tag:"path",attrs:{d:"M5 10v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-8"}},{tag:"path",attrs:{d:"M12 11v5"}},{tag:"path",attrs:{d:"m9.5 13.5 2.5 2.5 2.5-2.5"}}]},chat_bubble:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M6 18.5 3.5 21v-5A7.5 7.5 0 0 1 11 4.5h2A7.5 7.5 0 0 1 20.5 12v.5A7.5 7.5 0 0 1 13 20H8.5"}}]},content_copy:{type:"stroke",shapes:[{tag:"rect",attrs:{x:"9",y:"9",width:"10",height:"10",rx:"2"}},{tag:"path",attrs:{d:"M7 15H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v1"}}]},check:{type:"stroke",shapes:[{tag:"path",attrs:{d:"m5 12 4.2 4.2L19 7.5"}}]},draft:{type:"stroke",shapes:[{tag:"rect",attrs:{x:"4",y:"5",width:"16",height:"14",rx:"2"}},{tag:"path",attrs:{d:"m5 7 7 5 7-5"}}]},save:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M5 20h14a1 1 0 0 0 1-1V7.5L16.5 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1Z"}},{tag:"path",attrs:{d:"M8 4v5h7"}},{tag:"rect",attrs:{x:"8",y:"14",width:"8",height:"4",rx:"1"}}]},download:{type:"stroke",shapes:[{tag:"path",attrs:{d:"M12 5v10"}},{tag:"path",attrs:{d:"m7.5 10.5 4.5 4.5 4.5-4.5"}},{tag:"path",attrs:{d:"M5 19h14"}}]}},s=Ft(()=>n[t.name]||n.close),r=Ft(()=>typeof t.size=="number"?`${t.size}px`:/^\d+(\.\d+)?$/.test(t.size)?`${t.size}px`:t.size);return(i,l)=>(L(),V("span",{class:"app-icon",style:Be({width:r.value,height:r.value}),"aria-hidden":"true"},[(L(),V("svg",{viewBox:"0 0 24 24",fill:s.value.type==="fill"?"currentColor":"none",stroke:s.value.type==="stroke"?"currentColor":"none","stroke-width":"1.8","stroke-linecap":"round","stroke-linejoin":"round"},[(L(!0),V(fe,null,Xt(s.value.shapes,(a,f)=>(L(),mt(Zl(a.tag),ao({key:`${e.name}-${f}`},{ref_for:!0},a.attrs),null,16))),128))],8,_c))],4))}},bc={class:"header"},wc={__name:"AppHeader",props:{theme:{type:String,required:!0}},emits:["toggle-theme"],setup(e){return(t,n)=>(L(),V("div",bc,[n[1]||(n[1]=y("h1",null,"AirShare Pro",-1)),n[2]||(n[2]=y("p",null,"跨端局域网 & P2P 传输中心",-1)),y("button",{class:"theme-toggle",title:"切换日夜模式",onClick:n[0]||(n[0]=s=>t.$emit("toggle-theme"))},[q(pe,{id:"theme-icon",name:e.theme==="dark"?"dark_mode":"light_mode",size:"22"},null,8,["name"])])]))}},xc={class:"card"},Ic={key:0,class:"section-title"},hn={__name:"GlassCard",props:{title:{type:String,default:""}},setup(e){return(t,n)=>(L(),V("div",xc,[e.title?(L(),V("div",Ic,ie(e.title),1)):We("",!0),ea(t.$slots,"default")]))}},Sc={class:"admin-panel active"},Tc={class:"card admin-header-card"},Cc={class:"transfer-head transfer-head-compact"},$c={class:"main-grid admin-summary-grid"},Mc={class:"admin-stats-panel"},Rc={class:"admin-stats-row"},Ac={class:"admin-fluid-content"},kc={class:"admin-fluid-icon"},Ec={class:"admin-fluid-copy"},Oc={key:0,class:"stat-suffix"},Pc={class:"admin-config-stack"},Dc={class:"text-input-group admin-config-row admin-config-row-field admin-config-row-last"},Fc={class:"admin-field-control-row"},Nc=["value"],Uc={class:"text-input-group admin-config-row admin-config-row-field admin-config-row-last"},Bc={class:"admin-field-control-row"},Lc=["value"],jc={class:"admin-config-insights"},Hc={class:"admin-config-highlight"},zc={class:"admin-config-highlight"},Kc={class:"admin-table-wrapper"},Wc={class:"admin-table"},Vc={__name:"AdminPanel",props:{stats:{type:Array,required:!0},records:{type:Array,required:!0},fileLimit:{type:Number,required:!0},minioCapacity:{type:Number,required:!0}},emits:["exit","save-config","update:file-limit","update:minio-capacity"],setup(e){function t(l){const a=Number(l)||0;return a>=1024?`${(a/1024).toFixed(a%1024===0?0:1)} GB`:`${a} MB`}function n(l){const a=Number(l)||0;return a>=1024?`${(a/1024).toFixed(a%1024===0?0:1)} TB`:`${a} GB`}function s(l){return l==="blue"?{color:"var(--accent-blue)"}:l==="cyan"?{color:"var(--accent-cyan)"}:l==="success"?{color:"var(--success-green)"}:l==="danger"?{color:"var(--danger-red)"}:{color:"var(--text-main)"}}function r(l){return l==="success"?{color:"var(--success-green)",fontWeight:500}:l==="primary"?{color:"var(--accent-blue)",fontWeight:500}:{color:"var(--danger-red)",fontWeight:500}}function i(l){const a=Number(l)||0;return{"--fluid-level":`${Math.max(0,Math.min(a,100))}%`}}return(l,a)=>(L(),V("div",Sc,[y("div",Tc,[y("div",Cc,[a[5]||(a[5]=y("div",{class:"connected-to"},[y("h2",{class:"admin-title"},"管理控制台"),y("p",{class:"admin-subtitle"},"AirShare Pro System Dashboard")],-1)),y("button",{class:"btn-small-primary",type:"button",onClick:a[0]||(a[0]=f=>l.$emit("exit"))},"退出管理")])]),y("div",$c,[q(hn,{class:"admin-stats-card",title:"系统运行状态"},{default:qt(()=>[y("div",Mc,[y("div",Rc,[(L(!0),V(fe,null,Xt(e.stats,f=>(L(),V("div",{key:f.label,class:xt(["admin-stat-item",{"admin-stat-item-fluid":f.kind==="minio"}])},[f.kind==="minio"?(L(),V("div",{key:0,class:"admin-fluid-card",style:Be(i(f.percent))},[a[6]||(a[6]=y("div",{class:"admin-fluid-fill"},[y("span",{class:"admin-fluid-wave admin-fluid-wave-a"}),y("span",{class:"admin-fluid-wave admin-fluid-wave-b"})],-1)),y("div",Ac,[y("div",kc,[q(pe,{name:"save",size:"18"})]),y("div",Ec,[y("h3",{style:Be(s(f.tone))},ie(f.value),5),y("p",null,ie(f.label),1),y("small",null,ie(f.detail),1)])])],4)):(L(),V(fe,{key:1},[a[7]||(a[7]=y("span",{class:"admin-stat-kicker"},"实时指标",-1)),y("h3",{style:Be(s(f.tone))},[xn(ie(f.value),1),f.suffix?(L(),V("span",Oc,ie(f.suffix),1)):We("",!0)],4),y("p",null,ie(f.label),1)],64))],2))),128))])])]),_:1}),q(hn,{class:"admin-config-card",title:"核心参数配置"},{default:qt(()=>[y("div",Pc,[y("div",Dc,[a[8]||(a[8]=y("div",{class:"admin-field-meta"},[y("label",{class:"admin-field-label",for:"admin-file-limit"},"单文件大小限制"),y("p",{class:"admin-field-hint"},"单位为 MB,超过该阈值的文件会按当前后端策略处理。")],-1)),y("div",Fc,[y("input",{id:"admin-file-limit",value:e.fileLimit,min:"1",placeholder:"10240",type:"number",onInput:a[1]||(a[1]=f=>l.$emit("update:file-limit",Number(f.target.value)||0))},null,40,Nc),y("button",{title:"保存配置",type:"button",onClick:a[2]||(a[2]=f=>l.$emit("save-config"))},[q(pe,{name:"save",size:"18"})])])]),y("div",Uc,[a[9]||(a[9]=y("div",{class:"admin-field-meta"},[y("label",{class:"admin-field-label",for:"admin-minio-capacity"},"MinIO 总容量"),y("p",{class:"admin-field-hint"},"单位为 GB,用于容量卡和液位比例计算。")],-1)),y("div",Bc,[y("input",{id:"admin-minio-capacity",value:e.minioCapacity,min:"1",placeholder:"120",type:"number",onInput:a[3]||(a[3]=f=>l.$emit("update:minio-capacity",Number(f.target.value)||0))},null,40,Lc),y("button",{title:"保存配置",type:"button",onClick:a[4]||(a[4]=f=>l.$emit("save-config"))},[q(pe,{name:"save",size:"18"})])])]),y("div",jc,[y("div",Hc,[a[10]||(a[10]=y("span",{class:"admin-config-badge"},"ACTIVE POLICY",-1)),y("h3",null,ie(t(e.fileLimit)),1),a[11]||(a[11]=y("p",null,"当前单文件阈值。超过该体积后,文件会按后端已设定的传输与存档策略处理。",-1))]),y("div",zc,[a[12]||(a[12]=y("span",{class:"admin-config-badge"},"MINIO CAPACITY",-1)),y("h3",null,ie(n(e.minioCapacity)),1),a[13]||(a[13]=y("p",null,"当前 MinIO 总容量基线,用于后台容量展示与液位占比计算。",-1))])])])]),_:1})]),q(hn,{class:"admin-table-card",title:"最近传输记录(Top 5)"},{default:qt(()=>[y("div",Kc,[y("table",Wc,[a[14]||(a[14]=y("thead",null,[y("tr",null,[y("th",null,"时间"),y("th",null,"发送端特征"),y("th",null,"传输类型"),y("th",null,"数据量"),y("th",null,"状态")])],-1)),y("tbody",null,[(L(!0),V(fe,null,Xt(e.records,f=>(L(),V("tr",{key:`${f.time}-${f.peer}`},[y("td",null,ie(f.time),1),y("td",null,ie(f.peer),1),y("td",null,ie(f.type),1),y("td",null,ie(f.size),1),y("td",{style:Be(r(f.tone))},ie(f.status),5)]))),128))])])])]),_:1})]))}},qc={key:0,class:"radar-container"},Gc={class:"radar"},Jc={key:1,class:"device-list"},Yc=["onClick"],Xc={class:"device-icon"},Zc={class:"device-info"},Qc={key:2,class:"radar-container"},eu={class:"radar"},tu={__name:"DeviceDiscoveryCard",props:{isScanning:{type:Boolean,required:!0},devices:{type:Array,required:!0}},emits:["select-device"],setup(e,{emit:t}){const n=t;function s(r){n("select-device",r)}return(r,i)=>(L(),mt(hn,{title:"局域网自动发现"},{default:qt(()=>[e.isScanning?(L(),V("div",qc,[y("div",Gc,[q(pe,{class:"radar-icon",name:"sensors",size:"36"})]),i[0]||(i[0]=y("p",{class:"scan-status"},"正在扫描附近设备...",-1))])):e.devices.length?(L(),V("div",Jc,[(L(!0),V(fe,null,Xt(e.devices,l=>(L(),V("button",{key:l.id,class:"device-item",type:"button",onClick:a=>s(l)},[y("div",Xc,[q(pe,{name:l.icon,size:"24"},null,8,["name"])]),y("div",Zc,[y("h4",null,ie(l.name),1),y("p",null,ie(l.description),1)]),i[1]||(i[1]=y("div",{class:"device-status-beacon","aria-hidden":"true"},[y("span",{class:"device-status-dot"}),y("span",{class:"device-status-ring"}),y("span",{class:"device-status-ring device-status-ring-delay"})],-1))],8,Yc))),128))])):(L(),V("div",Qc,[y("div",eu,[q(pe,{class:"radar-icon",name:"devices",size:"36"})]),i[2]||(i[2]=y("p",{class:"scan-status"},"暂未发现局域网设备,请保持页面开启后重试",-1))]))]),_:1}))}},nu={key:0,class:"room-action-area"},su={class:"room-input-group"},ru=["value"],iu={key:0,class:"pending-downloads"},ou={class:"pending-downloads-head"},lu=["href"],au={class:"pending-download-copy"},cu={key:1,class:"waiting-area"},uu={class:"huge-code"},fu={__name:"RemoteRoomCard",props:{roomCodeInput:{type:String,required:!0},isWaiting:{type:Boolean,required:!0},generatedCode:{type:String,required:!0},pendingDownloads:{type:Array,required:!0}},emits:["update-room-code","create-room","join-room","cancel-room"],setup(e,{emit:t}){const n=t;function s(i){n("update-room-code",i.target.value)}function r(){n("join-room")}return(i,l)=>(L(),mt(hn,{title:"远程直连"},{default:qt(()=>[e.isWaiting?(L(),V("div",cu,[l[6]||(l[6]=y("p",{class:"waiting-subtitle"},"您的房间号码",-1)),y("div",uu,ie(e.generatedCode),1),l[7]||(l[7]=y("div",{class:"spinner"},null,-1)),l[8]||(l[8]=y("p",{class:"waiting-tip"},"等待对方加入...",-1)),y("button",{class:"btn-cancel",type:"button",onClick:l[2]||(l[2]=a=>i.$emit("cancel-room"))},"取消建房")])):(L(),V("div",nu,[y("button",{class:"btn-create",type:"button",onClick:l[0]||(l[0]=a=>i.$emit("create-room"))},[q(pe,{name:"add_circle",size:"22"}),l[3]||(l[3]=xn(" 创建专属传输房间 ",-1))]),l[5]||(l[5]=y("div",{class:"divider"},"或",-1)),y("div",su,[y("input",{class:"room-code",inputmode:"numeric",maxlength:"4",pattern:"\\d*",placeholder:"输入4位房间号",type:"text",value:e.roomCodeInput,onInput:s,onKeyup:po(r,["enter"])},null,40,ru),y("button",{class:"btn-primary",type:"button",onClick:l[1]||(l[1]=a=>i.$emit("join-room"))},"加入房间")]),e.pendingDownloads.length?(L(),V("div",iu,[y("div",ou,[l[4]||(l[4]=y("span",null,"待领取文件",-1)),y("span",null,ie(e.pendingDownloads.length),1)]),(L(!0),V(fe,null,Xt(e.pendingDownloads,a=>(L(),V("a",{key:a.transfer_id,class:"pending-download-item",href:a.download_path,target:"_blank",rel:"noopener noreferrer"},[y("div",au,[y("strong",null,ie(a.name),1),y("p",null,ie(a.size_label)+" · "+ie(a.created_label),1)]),q(pe,{name:"download",size:"18"})],8,lu))),128))])):We("",!0)]))]),_:1}))}},du={class:"file-info"},pu=["title"],hu={class:"file-info-right"},mu=["download","href"],gu={key:0,class:"progress-bar-container"},vu={__name:"TransferQueueItem",props:{item:{type:Object,required:!0}},emits:["remove","start-upload","copy"],setup(e){const t=e,n=Ft(()=>t.item.tone==="success"?{color:"var(--success-green)"}:t.item.tone==="primary"?{color:"var(--accent-blue)"}:t.item.tone==="danger"?{color:"var(--danger-red)"}:{color:"var(--text-secondary)"}),s=Ft(()=>t.item.kind==="text"?{color:"var(--success-green)",background:"rgba(48, 209, 88, 0.1)"}:{});return(r,i)=>(L(),V("div",{class:xt(["batch-item",{"pending-file":e.item.kind==="file"&&e.item.pending}])},[y("div",du,[y("div",{class:"file-info-left",style:Be(e.item.kind==="text"?{maxWidth:"70%"}:null)},[y("div",{class:"file-icon-wrapper",style:Be(s.value)},[q(pe,{name:e.item.kind==="text"?"chat_bubble":"draft",size:"18"},null,8,["name"])],4),y("span",{class:"file-name",title:e.item.kind==="text"?e.item.text:e.item.name},ie(e.item.kind==="text"?e.item.text:e.item.name),9,pu)],4),y("div",hu,[y("span",{class:"file-status",style:Be(n.value)},ie(e.item.kind==="text"&&e.item.copied?"已复制":e.item.status),5),e.item.kind==="text"?(L(),V("button",{key:0,class:"action-btn",title:"复制文本",type:"button",onClick:i[0]||(i[0]=l=>r.$emit("copy",e.item.id))},[q(pe,{name:e.item.copied?"check":"content_copy",size:"16"},null,8,["name"])])):We("",!0),e.item.kind==="file"&&e.item.pending?(L(),V("button",{key:1,class:"action-btn primary",title:"发送文件",type:"button",onClick:i[1]||(i[1]=l=>r.$emit("start-upload",e.item.id))},[q(pe,{name:"arrow_upward",size:"16"})])):We("",!0),e.item.kind==="file"&&e.item.downloadUrl?(L(),V("a",{key:2,class:"action-btn primary",download:e.item.name,href:e.item.downloadUrl,title:"保存文件"},[q(pe,{name:"download",size:"16"})],8,mu)):We("",!0),y("button",{class:"action-btn danger",title:"移除记录",type:"button",onClick:i[2]||(i[2]=l=>r.$emit("remove",e.item.id))},[q(pe,{name:"close",size:"16"})])])]),e.item.kind==="file"?(L(),V("div",gu,[y("div",{class:xt(["progress-bar-fill",{success:e.item.tone==="success"}]),style:Be({width:`${e.item.progress}%`})},null,6)])):We("",!0)],2))}},yu={class:"transfer-panel active"},_u={class:"card"},bu={class:"transfer-head"},wu={class:"connected-to"},xu={class:"text-input-group"},Iu={__name:"TransferPanel",props:{peerName:{type:String,required:!0},connectionType:{type:String,required:!0},items:{type:Array,required:!0},hasPendingItems:{type:Boolean,required:!0}},emits:["close","send-text","files-selected","send-all-pending","remove-item","start-upload","copy-item"],setup(e,{emit:t}){const n=e,s=t,r=oe(""),i=oe(!1),l=oe(null),a=oe(null);Dt(()=>n.items.length,async()=>{await Ri(),l.value&&(l.value.scrollTop=l.value.scrollHeight)});function f(){var C;(C=a.value)==null||C.click()}function m(){s("send-text",r.value),r.value=""}function p(C){const S=Array.from(C.target.files||[]);S.length&&s("files-selected",S),C.target.value=""}function _(C){var U;i.value=!1;const S=Array.from(((U=C.dataTransfer)==null?void 0:U.files)||[]);S.length&&s("files-selected",S)}return(C,S)=>(L(),V("div",yu,[y("div",_u,[y("div",bu,[y("div",wu,[y("h2",null,ie(e.peerName),1),y("p",null,ie(e.connectionType),1)]),y("button",{class:"close-btn",type:"button",onClick:S[0]||(S[0]=U=>C.$emit("close"))},[q(pe,{name:"close",size:"20"})])]),y("div",{class:xt(["drop-zone",{"drop-zone-active":i.value}]),onClick:f,onDragenter:S[1]||(S[1]=kn(U=>i.value=!0,["prevent"])),onDragover:S[2]||(S[2]=kn(U=>i.value=!0,["prevent"])),onDragleave:S[3]||(S[3]=kn(U=>i.value=!1,["prevent"])),onDrop:kn(_,["prevent"])},[q(pe,{class:"drop-zone-icon",name:"cloud_upload",size:"42"}),S[9]||(S[9]=y("p",{class:"drop-zone-text"},"点击或拖拽多个文件到这里",-1)),y("input",{ref_key:"fileInput",ref:a,class:"hidden",multiple:"",type:"file",onChange:p},null,544)],34),y("div",xu,[Ol(y("input",{"onUpdate:modelValue":S[4]||(S[4]=U=>r.value=U),placeholder:"输入要发送的文本或链接...",type:"text",onKeyup:po(m,["enter"])},null,544),[[ac,r.value]]),y("button",{title:"发送文本",type:"button",onClick:m},[q(pe,{name:"arrow_upward",size:"20"})])]),y("div",{class:xt(["batch-actions",{active:e.hasPendingItems}])},[y("button",{class:"btn-small-primary",type:"button",onClick:S[5]||(S[5]=U=>C.$emit("send-all-pending"))},[q(pe,{name:"send_and_archive",size:"16"}),S[10]||(S[10]=xn(" 一键发送全部 ",-1))])],2),e.items.length?(L(),V("div",{key:0,ref_key:"batchContainer",ref:l,class:"batch-progress-container"},[(L(!0),V(fe,null,Xt(e.items,U=>(L(),mt(vu,{key:U.id,item:U,onCopy:S[6]||(S[6]=D=>C.$emit("copy-item",D)),onRemove:S[7]||(S[7]=D=>C.$emit("remove-item",D)),onStartUpload:S[8]||(S[8]=D=>C.$emit("start-upload",D))},null,8,["item"]))),128))],512)):We("",!0)])]))}};let ln={deviceId:"",token:""};function ho(){return!ln.deviceId||!ln.token?{}:{"X-Device-ID":ln.deviceId,"X-Device-Token":ln.token}}function Su(e={},t=!1){return{...t?{"Content-Type":"application/json"}:{},...ho(),...e}}function Tu(e,t){if(!t||Object.keys(t).length===0)return e;const n=new URLSearchParams;Object.entries(t).forEach(([r,i])=>{i!=null&&i!==""&&n.set(r,String(i))});const s=n.toString();return s?`${e}?${s}`:e}async function En(e,t={}){const n=t.body!==void 0,s=await fetch(Tu(e,t.query),{method:t.method||"GET",headers:Su(t.headers,n),body:n?JSON.stringify(t.body):void 0}),r=await s.json().catch(()=>({}));if(!s.ok){const i=new Error(r.error||`Request failed: ${s.status}`);throw i.status=s.status,i}return r.data}const ve={get(e,t={}){return En(e,{...t,method:"GET"})},post(e,t,n={}){return En(e,{...n,method:"POST",body:t})},put(e,t,n={}){return En(e,{...n,method:"PUT",body:t})},patch(e,t,n={}){return En(e,{...n,method:"PATCH",body:t})}};function ti(e,t){ln={deviceId:e||"",token:t||""}}function Cu(){return ho()}function $u(e){return{Authorization:`Bearer ${e}`}}function On(e){return{headers:$u(e)}}const jt={login(e,t){return ve.post("/api/admin/login",{username:e,password:t})},stats(e){return ve.get("/api/admin/stats",On(e))},config(e){return ve.get("/api/admin/config",On(e))},updateConfig(e,t){return ve.put("/api/admin/config",t,On(e))},recentTransfers(e){return ve.get("/api/admin/transfers/recent",On(e))}},Pn={register(e){return ve.post("/api/devices/register",e)},heartbeat(e){return ve.post("/api/devices/heartbeat",{device_id:e})},listCandidates(e){return ve.get("/api/devices/candidates",{query:{deviceId:e}})},listPendingDownloads(e){return ve.get(`/api/devices/${encodeURIComponent(e)}/pending-downloads`)}},Dn={create(e){return ve.post("/api/rooms",{creator_device_id:e})},get(e){return ve.get(`/api/rooms/${encodeURIComponent(e)}`)},join(e,t){return ve.post("/api/rooms/join",{code:e,joiner_device_id:t})},cancel(e,t){return ve.post(`/api/rooms/${encodeURIComponent(e)}/cancel`,{requester_id:t})}},Mu={config(){return ve.get("/api/runtime/config")}},ge={create(e){return ve.post("/api/transfers",e)},presignFallback(e){return ve.post(`/api/transfers/${encodeURIComponent(e)}/fallback/presign`,{})},uploadFallback(e,t,n){return Ru(`/api/transfers/${encodeURIComponent(e)}/fallback/upload`,t,n)},updateStatus(e,t){return ve.patch(`/api/transfers/${encodeURIComponent(e)}/status`,t)}};function Ru(e,t,n){return new Promise((s,r)=>{const i=new XMLHttpRequest;i.open("PUT",e),i.responseType="json",i.setRequestHeader("Content-Type",t.type||"application/octet-stream"),Object.entries(Cu()).forEach(([l,a])=>{i.setRequestHeader(l,a)}),i.upload.onprogress=l=>{!l.lengthComputable||typeof n!="function"||n(Math.round(l.loaded/l.total*100))},i.onload=()=>{const l=i.response||Au(i.responseText);if(i.status>=200&&i.status<300){s(l.data);return}r(new Error((l==null?void 0:l.error)||`Upload failed: ${i.status}`))},i.onerror=()=>r(new Error("Upload failed")),i.send(t)})}function Au(e){try{return JSON.parse(e)}catch{return null}}const ku={class:"container"},Eu={key:0,class:"main-grid"},xs="filefast-admin-token",Fn="filefast-admin-view",Nn="filefast-device-id",ni="filefast-device-name",Is="filefast-device-token",Ou=15e3,Pu=5e3,Du=2e3,Fu=3e3,Nu=4*1024*1024,Uu=2e4,Bu=16*1024,si=512*1024,Lu={__name:"App",setup(e){const t=oe(localStorage.getItem("airshare-theme")||"light"),n=oe(localStorage.getItem(Fn)==="admin"?"admin":"main"),s=oe(!0),r=oe([]),i=oe(""),l=oe(!1),a=oe("----"),f=oe([]),m=oe({name:"--",type:"等待连接",deviceId:""}),p=oe([]),_=oe("/ws"),C=oe(10240),S=oe(120),U=oe([]),D=oe([]),Q=oe(null),K=oe(localStorage.getItem(xs)||""),A=oe({id:"",name:"",type:""}),ee=localStorage.getItem(Nn)||"",F=localStorage.getItem(Is)||"";ee&&F&&ti(ee,F);const le=new Map,he=new Map,$e=new Map,Me=new Map;let vt=null,je=null,Je=null,He=null,yt=null,de=null,st=null,H=null,j=null,G="",_e="p2p",ze=!1,be=!1,ce=!1,_t=null,St=null;const Sn=Ft(()=>p.value.filter(o=>o.kind==="file"&&o.pending)),Tt=Ft(()=>Sn.value.length>0);Dt(t,o=>{document.body.setAttribute("data-theme",o),localStorage.setItem("airshare-theme",o)},{immediate:!0}),Dt(n,o=>{if(o==="admin"&&K.value){localStorage.setItem(Fn,"admin");return}localStorage.removeItem(Fn)}),Dt([n,K],([o,c])=>{He&&(window.clearInterval(He),He=null),!(o!=="admin"||!c)&&(He=window.setInterval(()=>{rs().catch(u=>{console.error(u)})},5e3))}),Bi(async()=>{_.value=Mo(),await Nt(),n.value==="admin"&&K.value&&rs().catch(o=>{console.error(o)}),je=window.setInterval(()=>{I()},Ou),vt=window.setInterval(()=>{h()},Pu),yt=window.setInterval(()=>{v()},1e4)}),Xs(()=>{vt&&window.clearInterval(vt),je&&window.clearInterval(je),He&&window.clearInterval(He),yt&&window.clearInterval(yt),T(),bt(),pr(),E()});async function Nt(){try{await Tn(),await d(),await h()}catch(o){window.alert(`后端连接失败:${o.message}`)}}function Qt(){t.value=t.value==="dark"?"light":"dark"}async function Tn(){try{rt(await Mu.config())}catch(o){console.error(o)}}function rt(o){o&&(Q.value=o,C.value=Math.round((o.max_minio_fallback_size_bytes||0)/1024/1024),S.value=Math.max(0,Math.round((o.minio_capacity_bytes||0)/1024/1024/1024)))}function nr(o){i.value=o.replace(/\D/g,"").slice(0,4)}async function d(){const o=So(),c=To(o),u=Co(),g=await Pn.register({device_id:o,name:c,type:u,network_group_key:window.location.hostname||"local"});localStorage.setItem(Nn,g.id),g.auth_token&&(localStorage.setItem(Is,g.auth_token),ti(g.id,g.auth_token)),A.value={id:g.id,name:g.name,type:g.type},await v(),dr()}async function h(){if(A.value.id)try{const o=await ge.create({kind:"text",name:"text-message",content:value,sender_device_id:A.value.id,receiver_device_id:m.value.deviceId});try{await Pe(o,value)}catch(c){console.warn("realtime text send failed, fallback to relay",c),await tn(o,value)}p.value.push({id:Ue("text"),transferId:o.id,kind:"text",text:value,status:"已发送",tone:"success",copied:!1})}catch(o){window.alert(`发送文本失败:${o.message}`)}}async function v(){if(!A.value.id){f.value=[];return}try{const o=await Pn.listPendingDownloads(A.value.id);f.value=o.map(c=>({...c,download_path:c.download_path||`/api/transfers/${encodeURIComponent(c.transfer_id)}/fallback/download`,size_label:Bt(Number(c.size_bytes||0)),created_label:cs(c.created_at)}))}catch(o){if((o==null?void 0:o.status)===404){f.value=[];return}console.error(o)}}async function I(){if(A.value.id)try{await Pn.heartbeat(A.value.id)}catch(o){console.error(o)}}async function w(){if(!A.value.id){window.alert("当前设备尚未注册到后端");return}try{const o=await Dn.create(A.value.id);a.value=o.code,l.value=!0,$(o.code)}catch(o){window.alert(`创建房间失败:${o.message}`)}}async function x(){const o=a.value;T();try{l.value&&o!=="----"&&await Dn.cancel(o,A.value.id)}catch(c){console.error(c)}finally{l.value=!1,a.value="----"}}async function R(){if(!(i.value.length<4))try{const o=await Dn.join(i.value,A.value.id),c=$t(o.creator_device_id);i.value="",b({deviceId:o.creator_device_id,name:(c==null?void 0:c.name)||`房间 ${o.code} 创建者`,type:"房间配对成功"})}catch(o){window.alert(`加入房间失败:${o.message}`)}}function $(o){T(),Je=window.setInterval(async()=>{try{const c=await Dn.get(o);if(c.status==="joined"&&c.joiner_device_id){const u=$t(c.joiner_device_id);b({deviceId:c.joiner_device_id,name:(u==null?void 0:u.name)||`房间 ${o} 对端`,type:"房间配对成功"});return}(c.status==="expired"||c.status==="canceled")&&(T(),l.value=!1,a.value="----")}catch(c){console.error(c)}},Du)}function T(){Je&&(window.clearInterval(Je),Je=null)}function b(o){const c=o.deviceId||o.id||"",u=o.connectionType||o.type||"点对点传输";T(),m.value.deviceId!==c&&(bt(),E()),m.value={name:o.name,type:o.connectionType||o.type||"点对点传输",deviceId:o.deviceId||o.id||""},l.value=!1,a.value="----",n.value="transfer",m.value.baseType=u,m.value.type=u,m.value.deviceId=c,Ke("正在建立实时通道"),it(c,{initiate:!0})}function P(o,c=!1){const u=o.deviceId||o.id||"",g=o.connectionType||o.type||"点对点传输";m.value.deviceId===u&&n.value==="transfer"||(bt(),c||E()),m.value={name:o.name,type:o.connectionType||o.type||"点对点传输",deviceId:u},l.value=!1,a.value="----",n.value="transfer",m.value.baseType=g,m.value.type=g,m.value.deviceId=u,Ke("正在建立实时通道"),it(u)}function k(){bt(),E(),m.value={name:"--",type:"等待连接",deviceId:""},n.value="main",m.value.baseType="等待连接",m.value.type="等待连接"}function E(){p.value.forEach(o=>en(o)),p.value=[],he.clear()}async function N(o){const c=o.trim();if(c){if(!m.value.deviceId){window.alert("当前没有可用的接收端");return}try{const u=await ge.create({kind:"text",name:"text-message",content:c,sender_device_id:A.value.id,receiver_device_id:m.value.deviceId});ue("transfer.created",m.value.deviceId,{transfer_id:u.id,kind:"text",name:"text-message",content:c,sender_device_id:A.value.id,sender_name:A.value.name,sender_type:A.value.type,receiver_device_id:m.value.deviceId,final_status:"completed",current_channel:"p2p",transport_options:is()}),await ge.updateStatus(u.id,{current_channel:"p2p",final_status:"completed"}),ue("transfer.updated",m.value.deviceId,{transfer_id:u.id,final_status:"completed",current_channel:"p2p"}),p.value.push({id:Ue("text"),transferId:u.id,kind:"text",text:c,status:"已发送",tone:"success",copied:!1})}catch(u){window.alert(`发送文本失败:${u.message}`)}}}function W(o){const c=o.filter(Boolean).map((u,g)=>({id:Ue(`file-${g}`),kind:"file",file:u,name:u.name,size:Bt(u.size),sizeBytes:u.size,status:"待发送",tone:"muted",progress:0,pending:!0,transferId:""}));c.length&&p.value.push(...c)}async function Y(o){const c=p.value.find(u=>u.id===o);if(!(!c||c.kind!=="file"||!c.pending)){if(!m.value.deviceId){window.alert("当前没有可用的接收端");return}c.pending=!1,c.status="创建传输中...",c.tone="primary";try{const u=await ge.create({kind:"file",name:c.name,size_bytes:c.sizeBytes,sender_device_id:A.value.id,receiver_device_id:m.value.deviceId});if(c.transferId=u.id,c.sizeBytes>Nu){await J(c,u);return}ue("transfer.created",m.value.deviceId,{transfer_id:u.id,kind:"file",name:c.name,size_bytes:c.sizeBytes,sender_device_id:A.value.id,sender_name:A.value.name,sender_type:A.value.type,receiver_device_id:m.value.deviceId,final_status:"connecting",current_channel:"p2p",transport_options:is()});try{await nn(c,u)}catch(g){console.warn("realtime file send failed, fallback to relay",g),await sr(c,u)}}catch(u){c.pending=!0,c.status=`发送失败:${u.message}`,c.tone="danger"}}}async function J(o,c){o.progress=0,o.status="上传准备中...";try{if(!c.fallback_allowed)throw new Error("当前文件过大,且未启用 MinIO 回退");await ge.presignFallback(o.transferId),ue("transfer.updated",m.value.deviceId,{transfer_id:o.transferId,final_status:"fallback_uploading",current_channel:"minio"}),o.status="上传中...";const u=await ge.uploadFallback(o.transferId,o.file,g=>{o.progress=Math.max(1,Math.min(g,99))});await ge.updateStatus(o.transferId,{current_channel:"minio",final_status:"completed"}),ue("transfer.updated",m.value.deviceId,{transfer_id:o.transferId,final_status:"completed",current_channel:"minio"}),ue("transfer.file",m.value.deviceId,{transfer_id:o.transferId,name:o.name,download_url:u.download_path||u.download_url}),o.progress=100,o.status="上传完成",o.tone="success"}catch(u){o.pending=!0,o.status=`上传失败:${u.message}`,o.tone="danger"}}async function we(){for(const o of Sn.value)await Y(o.id)}async function xe(o){const c=p.value.find(u=>u.id===o);if(c&&en(c),p.value=p.value.filter(u=>u.id!==o),!(!(c!=null&&c.transferId)||c.tone==="success"))try{await ge.updateStatus(c.transferId,{final_status:"cancelled"}),ue("transfer.updated",m.value.deviceId,{transfer_id:c.transferId,final_status:"cancelled"})}catch(u){console.error(u)}}async function Fe(o){const c=p.value.find(u=>u.id===o);if(!(!c||c.kind!=="text"))try{await navigator.clipboard.writeText(c.text),c.copied=!0,window.setTimeout(()=>{const u=p.value.find(g=>g.id===o);u&&u.kind==="text"&&(u.copied=!1)},2e3)}catch{window.alert("复制失败")}}function Ne(o){const c=le.get(o);c&&(window.clearInterval(c),le.delete(o))}function Ct(o){return new Promise((c,u)=>{const g=new FileReader;g.onload=()=>c(String(g.result||"")),g.onerror=()=>u(new Error("Failed to read file")),g.readAsDataURL(o)})}function en(o){if(Ne(o.id),o.ownedDownloadUrl&&o.downloadUrl)try{URL.revokeObjectURL(o.downloadUrl)}catch(c){console.error(c)}o.transferId&&he.delete(o.transferId)}function me(o,c,u=!1){if(o.ownedDownloadUrl&&o.downloadUrl&&o.downloadUrl!==c)try{URL.revokeObjectURL(o.downloadUrl)}catch(g){console.error(g)}o.downloadUrl=c,o.ownedDownloadUrl=u}async function Pe(o,c){const u=await lr(m.value.deviceId);Cn(u,{type:"text",transfer_id:o.id,text:c,sender_device_id:A.value.id,sender_name:A.value.name,sender_type:A.value.type});const g=fr();await ge.updateStatus(o.id,{current_channel:g,final_status:"completed"})}async function tn(o,c){ue("transfer.created",m.value.deviceId,{transfer_id:o.id,kind:"text",name:"text-message",content:c,sender_device_id:A.value.id,sender_name:A.value.name,sender_type:A.value.type,receiver_device_id:m.value.deviceId,final_status:"completed",current_channel:"p2p"}),await ge.updateStatus(o.id,{current_channel:"p2p",final_status:"completed"})}async function nn(o,c){var O;const u=await lr(m.value.deviceId);o.status="正在通过 WebRTC 发送...",o.progress=1,Cn(u,{type:"file-meta",transfer_id:c.id,name:o.name,mime_type:((O=o.file)==null?void 0:O.type)||"application/octet-stream",size_bytes:o.sizeBytes,sender_device_id:A.value.id,sender_name:A.value.name,sender_type:A.value.type});let g=0;for(;gsi;)await _o(20)}function _o(o){return new Promise(c=>{window.setTimeout(c,o)})}function bo(o,c,u){return new Promise((g,M)=>{const O=window.setTimeout(()=>{M(new Error(u))},c);o.then(ne=>{window.clearTimeout(O),g(ne)}).catch(ne=>{window.clearTimeout(O),M(ne)})})}async function wo(){const o=window.prompt("管理员用户名","admin");if(o===null)return;const c=window.prompt("管理员密码");if(c!==null)try{const u=await jt.login(o.trim()||"admin",c);K.value=u.token,localStorage.setItem(xs,u.token),await rs(),n.value="admin"}catch(u){window.alert(`管理员登录失败:${u.message}`)}}function xo(){n.value="main"}async function rs(){if(K.value)try{const[o,c,u]=await Promise.all([jt.stats(K.value),jt.config(K.value),jt.recentTransfers(K.value)]);rt(c),U.value=vr(o.stats||{},o.minio||{}),D.value=u.map(g=>Lo(g))}catch(o){throw(o==null?void 0:o.status)===401&&(localStorage.removeItem(xs),localStorage.removeItem(Fn),K.value="",n.value="main"),o}}async function Io(){if(!K.value||!Q.value){window.alert("当前没有可用的管理员会话");return}try{const o={...Q.value,max_minio_fallback_size_bytes:Math.max(0,C.value)*1024*1024,minio_capacity_bytes:Math.max(0,S.value)*1024*1024*1024},c=await jt.updateConfig(K.value,o);rt(c);{const u=await jt.stats(K.value);U.value=vr(u.stats||{},u.minio||{})}window.alert("配置已保存")}catch(o){window.alert(`保存配置失败:${o.message}`)}}function So(){let o=localStorage.getItem(Nn);return o||(o=typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():`web-${Date.now()}-${Math.random().toString(36).slice(2,8)}`,localStorage.setItem(Nn,o)),o}function To(o){var u;let c=localStorage.getItem(ni);return c||(c=`${((u=navigator.userAgentData)==null?void 0:u.platform)||navigator.platform||"Web"} ${o.slice(0,4)}`,localStorage.setItem(ni,c)),c}function Co(){const o=`${navigator.userAgent} ${navigator.platform}`.toLowerCase();return o.includes("iphone")||o.includes("android")||o.includes("mobile")?"phone":o.includes("ipad")||o.includes("tablet")?"tablet":"desktop"}function $o(o){return o==="phone"?"smartphone":o==="tablet"?"tablet_mac":"laptop_mac"}function Ut(o){return o==="phone"?"手机":o==="tablet"?"平板":"桌面端"}function Mo(){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/ws`}function rr(o){const c=Array.isArray(o==null?void 0:o.turn_urls)?o.turn_urls.map(u=>String(u||"").trim()).filter(Boolean):[];return c.length?[{urls:c,username:(o==null?void 0:o.turn_username)||"",credential:(o==null?void 0:o.turn_password)||""}]:[]}function is(){var o,c;return{ice_servers:rr(Q.value),p2p_connect_timeout_sec:((o=Q.value)==null?void 0:o.p2p_connect_timeout_sec)||15,turn_connect_timeout_sec:((c=Q.value)==null?void 0:c.turn_connect_timeout_sec)||20}}function ir(){return typeof RTCPeerConnection<"u"}function os(){_t=null,St=null}function Ro(){return _t||(_t=new Promise(o=>{St=o})),_t}function or(o){St&&St(o),_t=Promise.resolve(o),St=null}function Ke(o=""){if(!m.value.deviceId)return;const c=m.value.baseType||m.value.type||"点对点传输";m.value={...m.value,type:o?`${c} · ${o}`:c}}async function it(o,c={}){return!o||!ir()?null:((!H||G!==o)&&(bt(),Ao(o)),c.initiate&&H.signalingState==="stable"&&await ko(o),H)}function Ao(o){G=o,_e="p2p",ze=!1,be=!1,ce=!1,Me.delete(o),os(),H=new RTCPeerConnection({iceServers:rr(Q.value)}),j=H.createDataChannel("filefast-control",{negotiated:!0,id:0,ordered:!0}),Eo(j),H.onicecandidate=({candidate:c})=>{if(c)try{ue("webrtc.candidate",o,{candidate:c})}catch(u){console.error(u)}},H.onconnectionstatechange=()=>{if(H){if(ls(),H.connectionState==="connected"){Ke(_e==="turn"?"TURN 中继已连接":"WebRTC 直连已连接");return}if(H.connectionState==="connecting"){Ke("实时通道连接中");return}if(H.connectionState==="failed"){Ke("实时通道连接失败");return}(H.connectionState==="disconnected"||H.connectionState==="closed")&&Ke("实时通道已断开")}},H.oniceconnectionstatechange=()=>{ls()}}async function ko(o){if(H)try{ze=!0,await H.setLocalDescription(),ue("webrtc.description",o,{description:H.localDescription})}finally{ze=!1}}function Eo(o){j=o,o.bufferedAmountLowThreshold=si/2,o.onopen=()=>{or(o),Ke(_e==="turn"?"TURN 中继已连接":"WebRTC 直连已连接"),ls()},o.onclose=()=>{j===o&&(j=null,os(),Ke("实时通道已关闭"))},o.onerror=c=>{console.error(c)},o.onmessage=c=>{Oo(c.data)},o.readyState==="open"&&or(o)}function bt(){he.clear(),G&&Me.delete(G),j&&(j.onopen=null,j.onclose=null,j.onerror=null,j.onmessage=null,j.close(),j=null),H&&(H.onicecandidate=null,H.onconnectionstatechange=null,H.oniceconnectionstatechange=null,H.close(),H=null),G="",_e="p2p",ze=!1,be=!1,ce=!1,os()}async function lr(o){if(!ir())throw new Error("当前浏览器不支持 WebRTC");if(await it(o,{initiate:!0}),(j==null?void 0:j.readyState)==="open")return j;const c=await bo(Ro(),Uu,"WebRTC 连接超时");if(!c||c.readyState!=="open")throw new Error("实时通道未建立");return c}function Cn(o,c){if(!o||o.readyState!=="open")throw new Error("实时通道未就绪");o.send(JSON.stringify(c))}function Oo(o){try{const c=JSON.parse(String(o||"{}"));if(c.type==="text"){Po(c);return}if(c.type==="file-meta"){Do(c);return}if(c.type==="file-chunk"){Fo(c);return}c.type==="file-complete"&&No(c)}catch(c){console.error(c)}}function Po(o){var M;const c=o.sender_device_id||G,u={id:c,name:o.sender_name||((M=$t(c))==null?void 0:M.name)||`设备 ${Mt(c)}`,type:Ut(o.sender_type||"desktop"),connectionType:_e==="turn"?"TURN 中继已连接":"WebRTC 直连已连接"};P(u,!0);const g=p.value.find(O=>O.transferId===o.transfer_id);if(g&&g.kind==="text"){g.text=o.text||"",g.status="已接收",g.tone="success";return}p.value.push({id:Ue("incoming-text"),transferId:o.transfer_id,kind:"text",text:o.text||"",status:"已接收",tone:"success",copied:!1})}function Do(o){var M;const c=o.sender_device_id||G,u={id:c,name:o.sender_name||((M=$t(c))==null?void 0:M.name)||`设备 ${Mt(c)}`,type:Ut(o.sender_type||"desktop"),connectionType:_e==="turn"?"TURN 中继已连接":"WebRTC 直连已连接"};P(u,!0),he.set(o.transfer_id,{name:o.name||"file",mimeType:o.mime_type||"application/octet-stream",sizeBytes:Number(o.size_bytes||0),receivedBytes:0,chunks:[]});let g=p.value.find(O=>O.transferId===o.transfer_id);g?(g.status="正在接收...",g.tone="primary",g.progress=0):(g={id:Ue("incoming-file"),transferId:o.transfer_id,kind:"file",name:o.name||"file",size:Bt(Number(o.size_bytes||0)),sizeBytes:Number(o.size_bytes||0),status:"正在接收...",tone:"primary",progress:0,pending:!1,downloadUrl:"",ownedDownloadUrl:!1},p.value.push(g))}function Fo(o){const c=he.get(o.transfer_id);if(!c)return;const u=vo(String(o.chunk_base64||""));c.receivedBytes+=Number(o.chunk_size||u.byteLength||0),c.chunks.push(u);const g=p.value.find(M=>M.transferId===o.transfer_id);if(g){const M=c.sizeBytes>0?c.receivedBytes/c.sizeBytes*100:0;g.progress=Math.max(1,Math.min(99,Math.round(M))),g.status="正在接收...",g.tone="primary"}}function No(o){const c=he.get(o.transfer_id);if(!c)return;const u=p.value.find(O=>O.transferId===o.transfer_id);if(!u){he.delete(o.transfer_id);return}const g=new Blob(c.chunks,{type:c.mimeType||"application/octet-stream"}),M=URL.createObjectURL(g);me(u,M,!0),u.progress=100,u.status="可保存",u.tone="success",he.delete(o.transfer_id)}function ar(o){return A.value.id.localeCompare(o)>0}function $n(o,c="等待实时数据"){const u=$t(o);return{id:o,deviceId:o,name:(u==null?void 0:u.name)||`设备 ${Mt(o)}`,type:Ut((u==null?void 0:u.type)||"desktop"),connectionType:c}}async function cr(o){const u=(o.payload||{}).description,g=o.device_id||"";if(!u||!g)return;P($n(g),!0);const M=await it(g);if(!M)return;const O=ar(g),ne=!ze&&(M.signalingState==="stable"||ce),Rt=u.type==="offer"&&!ne;be=!O&&Rt,!be&&(ce=u.type==="answer",await M.setRemoteDescription(u),ce=!1,u.type==="offer"&&(await M.setLocalDescription(),ue("webrtc.description",g,{description:M.localDescription})))}async function ur(o){const c=o.payload||{},u=o.device_id||"";if(!c.candidate||!u)return;(n.value!=="transfer"||m.value.deviceId!==u)&&P($n(u),!0);const g=await it(u);if(g)try{await g.addIceCandidate(c.candidate)}catch(M){be||console.error(M)}}async function ls(){if(!(!H||H.connectionState!=="connected"))try{const o=await H.getStats();let c=null;if(o.forEach(O=>{O.type==="transport"&&O.selectedCandidatePairId&&(c=o.get(O.selectedCandidatePairId)||c)}),c||o.forEach(O=>{O.type==="candidate-pair"&&O.state==="succeeded"&&(O.nominated||O.selected)&&(c=O)}),!c)return;const u=o.get(c.localCandidateId),g=o.get(c.remoteCandidateId),M=(u==null?void 0:u.candidateType)==="relay"||(g==null?void 0:g.candidateType)==="relay";_e=M?"turn":"p2p",(j==null?void 0:j.readyState)==="open"&&Ke(M?"TURN 中继已连接":"WebRTC 直连已连接")}catch(o){console.error(o)}}function fr(){return _e==="turn"?"turn":"p2p"}function dr(){if(!A.value.id)return;const o=localStorage.getItem(Is)||"";o&&(pr(),de=new WebSocket(`${_.value}?deviceId=${encodeURIComponent(A.value.id)}&deviceToken=${encodeURIComponent(o)}`),de.addEventListener("message",c=>{Bo(c.data)}),de.addEventListener("close",()=>{de=null,Uo()}),de.addEventListener("error",()=>{de==null||de.close()}))}function pr(){if(st&&(window.clearTimeout(st),st=null),!de)return;const o=de;de=null,o.onclose=null,o.close()}function Uo(){st||!A.value.id||(st=window.setTimeout(()=>{st=null,dr()},Fu))}function ue(o,c,u){!de||de.readyState!==WebSocket.OPEN||!c||de.send(JSON.stringify({type:o,target_device_id:c,payload:u}))}function Bo(o){try{const c=JSON.parse(o);if(c.type==="presence.update"){h();return}if(c.type==="webrtc.description"){cr(c);return}if(c.type==="webrtc.candidate"){ur(c);return}if(c.type==="transfer.created"){hr(c);return}if(c.type==="transfer.updated"){mr(c);return}c.type==="transfer.file"&&gr(c)}catch(c){console.error(c)}}function hr(o){var O;const c=o.payload||{},u=o.device_id||c.sender_device_id||"",g={id:u,name:c.sender_name||((O=$t(u))==null?void 0:O.name)||`Device ${Mt(u)}`,type:Ut(c.sender_type||"desktop")};if(g.connectionType="等待实时数据",P(g,!0),!p.value.find(ne=>ne.transferId===c.transfer_id)){if(c.kind==="text"){p.value.push({id:Ue("incoming-text"),transferId:c.transfer_id,kind:"text",text:c.content||"",status:"已接收",tone:"success",copied:!1});return}p.value.push({id:Ue("incoming-file"),transferId:c.transfer_id,kind:"file",name:c.name||"file",size:Bt(Number(c.size_bytes||0)),sizeBytes:Number(c.size_bytes||0),status:"接收中...",tone:"primary",progress:35,pending:!1,downloadUrl:"",ownedDownloadUrl:!1})}}function mr(o){const c=o.payload||{},u=p.value.find(g=>g.transferId===c.transfer_id);if(u&&u.kind==="file"){if(c.final_status==="completed"){u.progress=100,u.status="已接收",u.tone="success",u.downloadUrl&&(u.status="可保存");return}c.final_status==="cancelled"&&(u.status="已取消",u.tone="danger")}}function gr(o){const c=o.payload||{};let u=p.value.find(g=>g.transferId===c.transfer_id);!u&&c.transfer_id&&(u={id:Ue("incoming-file"),transferId:c.transfer_id,kind:"file",name:c.name||"file",size:"",sizeBytes:0,status:"可保存",tone:"success",progress:100,pending:!1,downloadUrl:"",ownedDownloadUrl:!1},p.value.push(u)),!(!u||u.kind!=="file")&&(me(u,c.download_url||c.data_url||"",!1),u.status="可保存",u.progress=100,u.tone="success")}function $t(o){return r.value.find(c=>c.id===o)}function vr(o,c={}){return[{label:"在线设备",value:`${o.devices_online||0}`,tone:"blue"},{label:"待加入房间",value:`${o.rooms_waiting||0}`,tone:"cyan"},{label:"有效传输",value:`${o.transfers_total||0}`,tone:"default"},{label:"累计传输",value:`${o.transfers_cumulative||0}`,tone:"default"},{kind:"minio",label:"MinIO 剩余容量",value:as(c.remaining_bytes||0),tone:Number(c.usage_percent||0)>=85?"danger":Number(c.usage_percent||0)>=60?"cyan":"blue",percent:Math.max(0,100-Number(c.usage_percent||0)),detail:`已用 ${as(c.used_bytes||0)} / 总计 ${as(c.capacity_bytes||0)}`,kicker:`存档 ${c.object_count||0} 份`}]}function as(o){const c=Number(o||0);if(!c||c<=0)return"0 GB";const u=["B","KB","MB","GB","TB"],g=Math.min(Math.floor(Math.log(c)/Math.log(1024)),u.length-1),M=c/1024**g,O=g>=3?2:M>=10?1:2;return`${M.toFixed(O)} ${u[g]}`}function Lo(o){const c=o.final_status==="completed",u=o.final_status==="failed"||o.final_status==="cancelled";return{time:cs(o.created_at),peer:`${Mt(o.sender_device_id)} -> ${Mt(o.receiver_device_id)}`,type:o.kind==="text"?"文本消息":`文件 ${o.name}`,size:Bt(Number(o.size_bytes||0)),status:c?`已完成 (${o.current_channel||"p2p"})`:u?`已结束 (${o.final_status})`:`进行中 (${o.final_status||"pending"})`,tone:c?"success":u?"danger":"primary"}}function Mt(o){return o?o.slice(0,8):"--"}function cs(o){if(!o)return"刚刚";const c=new Date(o),u=Date.now()-c.getTime();if(!Number.isFinite(u))return"刚刚";const g=Math.max(0,Math.floor(u/1e3));if(g<60)return`${g} 秒前`;const M=Math.floor(g/60);if(M<60)return`${M} 分钟前`;const O=Math.floor(M/60);return O<24?`${O} 小时前`:`${Math.floor(O/24)} 天前`}function Ue(o){return`${o}-${Date.now()}-${Math.random().toString(36).slice(2,8)}`}function Bt(o){if(!o||o<=0)return"0 B";const c=["B","KB","MB","GB","TB"],u=Math.min(Math.floor(Math.log(o)/Math.log(1024)),c.length-1),g=o/1024**u,M=g>=10||u===0?0:1;return`${g.toFixed(M)} ${c[u]}`}h=async function(){return A.value.id?Pn.listCandidates(A.value.id).then(c=>{r.value=c.map(u=>({...u,description:`${Ut(u.type)} · 最近活跃 ${cs(u.last_seen_at)}`,icon:$o(u.type),connectionType:u.network_group_key&&u.network_group_key===window.location.hostname?"局域网直连优先":"跨网络实时传输"})),s.value=r.value.length===0}).catch(c=>{s.value=!1,console.error(c)}):Promise.resolve()},b=function(c){const u=c.deviceId||c.id||"",g=c.connectionType||c.type||"点对点传输";T(),m.value.deviceId!==u&&(bt(),E()),m.value={name:c.name,type:g,baseType:g,deviceId:u},l.value=!1,a.value="----",n.value="transfer",Ke("正在建立实时通道"),it(u,{initiate:!0})},P=function(c,u=!1){const g=c.deviceId||c.id||"",M=c.connectionType||c.type||"点对点传输";m.value.deviceId===g&&n.value==="transfer"||(bt(),u||E()),m.value={name:c.name,type:M,baseType:M,deviceId:g},l.value=!1,a.value="----",n.value="transfer",g&&(Ke("正在建立实时通道"),it(g))},k=function(){bt(),E(),m.value={name:"--",type:"等待连接",baseType:"等待连接",deviceId:""},n.value="main"},N=async function(c){const u=c.trim();if(u){if(!m.value.deviceId){window.alert("当前没有可用的接收端");return}try{const g=await ge.create({kind:"text",name:"text-message",content:u,sender_device_id:A.value.id,receiver_device_id:m.value.deviceId});try{await Pe(g,u)}catch(M){console.warn("realtime text send failed, fallback to relay",M),await tn(g,u)}p.value.push({id:Ue("text"),transferId:g.id,kind:"text",text:u,status:"已发送",tone:"success",copied:!1})}catch(g){window.alert(`发送文本失败:${g.message}`)}}};function yr(o,c){ue("transfer.file",m.value.deviceId,{transfer_id:o.transferId,name:o.name,download_url:c.download_path||c.download_url})}function _r(o,c,{onProgress:u}={}){if(!(o!=null&&o.file))return Promise.reject(new Error("未找到待上传文件"));if(!(c!=null&&c.fallback_allowed))return Promise.reject(new Error("MinIO 存档未启用"));const g=c.id;if($e.has(g))return $e.get(g);const M=(async()=>(await ge.presignFallback(g),ge.uploadFallback(g,o.file,O=>{typeof u=="function"&&u(O)})))().finally(()=>{$e.delete(g)});return $e.set(g,M),M}async function br(o,c,u){await ge.updateStatus(c.id,{current_channel:"minio",final_status:"completed"}),ue("transfer.updated",m.value.deviceId,{transfer_id:c.id,final_status:"completed",current_channel:"minio"}),yr(o,u),o.progress=100,o.status="已上传到 MinIO,对方可直接领取",o.tone="success"}async function jo(o,c){const u=Me.get(o);if(!(!(u!=null&&u.length)||!(c!=null&&c.remoteDescription))){Me.delete(o);for(const g of u)try{await c.addIceCandidate(g)}catch(M){console.error(M)}}}return J=async function(c,u){c.progress=Math.max(5,c.progress||0),c.status="正在切换到 MinIO...",c.tone="primary";try{ue("transfer.updated",m.value.deviceId,{transfer_id:c.transferId,final_status:"fallback_uploading",current_channel:"minio"});const g=await _r(c,u,{onProgress:M=>{c.progress=Math.max(5,Math.min(M,99))}});await br(c,u,g)}catch(g){c.pending=!0,c.status=`上传失败:${g.message}`,c.tone="danger"}},cr=async function(c){const g=(c.payload||{}).description,M=c.device_id||"";if(!g||!M)return;P($n(M),!0);const O=await it(M);if(!O)return;const ne=ar(M),Rt=!ze&&(O.signalingState==="stable"||ce),Ho=g.type==="offer"&&!Rt;if(be=!ne&&Ho,!be&&!(g.type==="answer"&&(O.signalingState!=="have-local-offer"||!O.localDescription))){try{ce=g.type==="answer",await O.setRemoteDescription(g),await jo(M,O)}catch(us){console.error(us)}finally{ce=!1}if(g.type==="offer")try{await O.setLocalDescription(),ue("webrtc.description",M,{description:O.localDescription})}catch(us){console.error(us)}}},ur=async function(c){const u=c.payload||{},g=c.device_id||"",M=u.candidate;if(!M||!g)return;(n.value!=="transfer"||m.value.deviceId!==g)&&P($n(g),!0);const O=await it(g);if(O){if(!O.remoteDescription){const ne=Me.get(g)||[];ne.push(M),Me.set(g,ne);return}try{await O.addIceCandidate(M)}catch(ne){be||console.error(ne)}}},Y=async function(c){const u=p.value.find(g=>g.id===c);if(!(!u||u.kind!=="file"||!u.pending)){if(!m.value.deviceId){window.alert("当前没有可用的接收端");return}u.pending=!1,u.status="创建传输中...",u.tone="primary";try{const g=await ge.create({kind:"file",name:u.name,size_bytes:u.sizeBytes,sender_device_id:A.value.id,receiver_device_id:m.value.deviceId});u.transferId=g.id;const M=g.fallback_allowed?_r(u,g).catch(O=>{throw console.warn("minio backup sync failed",O),O}):Promise.resolve(null);ue("transfer.created",m.value.deviceId,{transfer_id:g.id,kind:"file",name:u.name,size_bytes:u.sizeBytes,sender_device_id:A.value.id,sender_name:A.value.name,sender_type:A.value.type,receiver_device_id:m.value.deviceId,final_status:"connecting",current_channel:"p2p",transport_options:is()});try{if(await nn(u,g),g.fallback_allowed){u.status="实时传输完成,正在同步云端备份...",u.tone="primary";try{const O=await M;O&&(yr(u,O),u.status="已发送,2 小时内可离线领取")}catch(O){u.status=`实时传输成功,但 MinIO 备份失败:${O.message}`,u.tone="danger";return}u.tone="success"}}catch(O){console.warn("realtime file send failed, fallback to minio",O);try{const ne=await M;if(ne){await br(u,g,ne);return}}catch(ne){console.warn("minio backup sync failed after realtime failure",ne)}await sr(u,g)}}catch(g){u.pending=!0,u.status=`发送失败:${g.message}`,u.tone="danger"}}},hr=function(c){var ne;const u=c.payload||{},g=c.device_id||u.sender_device_id||"",M={id:g,name:u.sender_name||((ne=$t(g))==null?void 0:ne.name)||`设备 ${Mt(g)}`,type:Ut(u.sender_type||"desktop"),connectionType:"等待实时数据"};if(P(M,!0),!p.value.find(Rt=>Rt.transferId===u.transfer_id)){if(u.kind==="text"){u.content&&p.value.push({id:Ue("incoming-text"),transferId:u.transfer_id,kind:"text",text:u.content||"",status:"已接收",tone:"success",copied:!1});return}p.value.push({id:Ue("incoming-file"),transferId:u.transfer_id,kind:"file",name:u.name||"file",size:Bt(Number(u.size_bytes||0)),sizeBytes:Number(u.size_bytes||0),status:"等待接收...",tone:"primary",progress:5,pending:!1,downloadUrl:"",ownedDownloadUrl:!1})}},mr=function(c){const u=c.payload||{},g=p.value.find(M=>M.transferId===u.transfer_id);if(g&&g.kind==="file"){if(u.final_status==="completed"){g.progress=100,g.status=g.downloadUrl?"可保存":"传输完成",g.tone="success";return}if(u.final_status==="cancelled"){g.status="已取消",g.tone="danger";return}u.final_status==="fallback_uploading"&&(g.status="发送端正在上传回退文件...",g.tone="primary")}},gr=function(c){const u=c.payload||{};let g=p.value.find(M=>M.transferId===u.transfer_id);!g&&u.transfer_id&&(g={id:Ue("incoming-file"),transferId:u.transfer_id,kind:"file",name:u.name||"file",size:"",sizeBytes:0,status:"可保存",tone:"success",progress:100,pending:!1,downloadUrl:"",ownedDownloadUrl:!1},p.value.push(g)),!(!g||g.kind!=="file")&&(me(g,u.download_url||u.data_url||"",!1),g.status="可保存",g.progress=100,g.tone="success")},(o,c)=>(L(),V("div",null,[y("div",ku,[q(wc,{theme:t.value,onToggleTheme:Qt},null,8,["theme"]),n.value==="main"?(L(),V("div",Eu,[q(tu,{devices:r.value,"is-scanning":s.value,onSelectDevice:b},null,8,["devices","is-scanning"]),q(fu,{"generated-code":a.value,"is-waiting":l.value,"pending-downloads":f.value,"room-code-input":i.value,onCancelRoom:x,onCreateRoom:w,onJoinRoom:R,onUpdateRoomCode:nr},null,8,["generated-code","is-waiting","pending-downloads","room-code-input"])])):We("",!0),n.value==="transfer"?(L(),mt(Iu,{key:1,"connection-type":m.value.type,"has-pending-items":Tt.value,items:p.value,"peer-name":m.value.name,onClose:k,onCopyItem:Fe,onFilesSelected:W,onRemoveItem:xe,onSendAllPending:we,onSendText:N,onStartUpload:Y},null,8,["connection-type","has-pending-items","items","peer-name"])):We("",!0),n.value==="admin"?(L(),mt(Vc,{key:2,"file-limit":C.value,"minio-capacity":S.value,records:D.value,stats:U.value,onExit:xo,onSaveConfig:Io,"onUpdate:fileLimit":c[0]||(c[0]=u=>C.value=u),"onUpdate:minioCapacity":c[1]||(c[1]=u=>S.value=u)},null,8,["file-limit","minio-capacity","records","stats"])):We("",!0)]),q(yc,{onRequestAdmin:wo})]))}};hc(Lu).mount("#app"); diff --git a/frontend/dist/assets/index-jSzxC_eO.css b/frontend/dist/assets/index-jSzxC_eO.css new file mode 100644 index 0000000..b9cb020 --- /dev/null +++ b/frontend/dist/assets/index-jSzxC_eO.css @@ -0,0 +1 @@ +:root{--bg-color: #09090b;--card-bg: rgba(24, 24, 27, .65);--card-border: rgba(255, 255, 255, .08);--card-shadow: 0 20px 40px -10px rgba(0, 0, 0, .5), 0 0 2px rgba(255, 255, 255, .05) inset;--text-main: #f4f4f5;--text-secondary: #a1a1aa;--item-bg: rgba(255, 255, 255, .03);--item-bg-hover: rgba(255, 255, 255, .06);--item-border: rgba(255, 255, 255, .05);--icon-bg: rgba(255, 255, 255, .05);--input-bg: rgba(0, 0, 0, .3);--divider-color: rgba(255, 255, 255, .15);--divider-line: rgba(255, 255, 255, .08);--glow-1: rgba(0, 113, 227, .15);--glow-2: rgba(34, 211, 238, .12);--glow-3: rgba(168, 85, 247, .1);--accent-blue: #0a84ff;--accent-cyan: #30d158;--accent-cyan-light: rgba(10, 132, 255, .2);--success-green: #30d158;--danger-red: #ff453a;--border-radius-lg: 28px;--border-radius-sm: 18px;--transition-base: all .4s cubic-bezier(.2, .8, .2, 1);--font-stack: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", Roboto, sans-serif}body[data-theme=light]{--bg-color: #f2f4f8;--card-bg: rgba(255, 255, 255, .85);--card-border: rgba(255, 255, 255, 1);--card-shadow: 0 10px 30px -5px rgba(12, 43, 100, .08), 0 4px 10px -3px rgba(12, 43, 100, .04), 0 0 1px rgba(0, 0, 0, .05);--text-main: #1d1d1f;--text-secondary: #86868b;--item-bg: #ffffff;--item-bg-hover: #f8fafd;--item-border: rgba(0, 0, 0, .04);--icon-bg: #f5f5f7;--input-bg: #ffffff;--divider-color: #86868b;--divider-line: #e5e5ea;--glow-1: rgba(0, 113, 227, .05);--glow-2: rgba(34, 211, 238, .05);--glow-3: rgba(168, 85, 247, .03);--accent-blue: #0071e3;--accent-cyan: #0071e3;--accent-cyan-light: rgba(0, 113, 227, .1);--success-green: #34c759;--danger-red: #ff3b30}*{box-sizing:border-box;margin:0;padding:0;outline:none}html,body,#app{min-height:100%}body{font-family:var(--font-stack);background-color:var(--bg-color);color:var(--text-main);display:flex;justify-content:center;align-items:center;min-height:100vh;padding:20px 20px 100px;position:relative;overflow-x:hidden;overflow-y:auto;transition:background-color .5s ease,color .5s ease;-webkit-font-smoothing:antialiased}body:before{content:"";position:fixed;top:-25%;right:-25%;bottom:-25%;left:-25%;width:150%;height:150%;z-index:-1;background-image:radial-gradient(circle at 20% 20%,var(--glow-1) 0%,transparent 50%),radial-gradient(circle at 80% 20%,var(--glow-2) 0%,transparent 40%),radial-gradient(circle at 50% 80%,var(--glow-3) 0%,transparent 60%);animation:backgroundMove 25s infinite alternate ease-in-out}body,button,input{font:inherit}button{border:0;color:inherit}a{color:inherit}.app-icon{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;line-height:1}.app-icon svg{width:100%;height:100%;overflow:visible}.container{width:100%;max-width:1000px;margin:auto;position:relative;z-index:1;transition:var(--transition-base)}.header{position:relative;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:80px;margin-top:30px;margin-bottom:40px;text-align:center}.header h1{font-size:42px;font-weight:800;letter-spacing:-1px;margin-bottom:8px}.header p{color:var(--text-secondary);font-size:16px;font-weight:400;letter-spacing:.5px}.theme-toggle{position:absolute;top:50%;right:0;width:44px;height:44px;margin-top:-22px;border-radius:50%;background:var(--card-bg);border:1px solid var(--card-border);color:var(--text-main);box-shadow:var(--card-shadow);cursor:pointer;display:flex;justify-content:center;align-items:center;transition:var(--transition-base);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px)}.theme-toggle:hover{transform:scale(1.08);box-shadow:0 8px 25px #0071e326}.main-grid{display:grid;grid-template-columns:1fr;gap:30px}.card{position:relative;display:flex;flex-direction:column;padding:40px 30px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--border-radius-lg);box-shadow:var(--card-shadow);transition:var(--transition-base);backdrop-filter:blur(40px) saturate(150%);-webkit-backdrop-filter:blur(40px) saturate(150%)}.card:before{content:"";position:absolute;inset:0 0 auto;height:1px;border-radius:var(--border-radius-lg) var(--border-radius-lg) 0 0;opacity:.5;background:linear-gradient(90deg,#fff0,#ffffff4d,#fff0)}.section-title{margin-bottom:25px;text-align:center;font-size:12px;font-weight:700;color:var(--text-secondary);text-transform:uppercase;letter-spacing:2px}.radar-container,.waiting-area{flex:1;min-height:200px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.radar{position:relative;width:80px;height:80px;margin-bottom:25px;display:flex;justify-content:center;align-items:center;border-radius:50%;background:var(--accent-cyan-light);border:1px solid rgba(0,113,227,.1);box-shadow:0 0 20px var(--accent-cyan-light)}.radar:before,.radar:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;border-radius:50%;border:2px solid var(--accent-blue);opacity:0;animation:ripplePro 3s infinite cubic-bezier(.1,.8,.3,1)}.radar:before{animation-delay:1.5s}.radar-icon{font-size:36px;color:var(--accent-blue)}.scan-status,.waiting-subtitle{margin-top:10px;font-size:14px;color:var(--text-secondary)}.device-list{width:100%;flex:1;display:flex;flex-direction:column;gap:12px}.device-item{position:relative;width:100%;display:flex;align-items:center;padding:16px 20px;background:var(--item-bg);border:1px solid var(--item-border);border-radius:var(--border-radius-sm);box-shadow:0 2px 8px #00000005;cursor:pointer;color:var(--text-main);text-align:left;transition:var(--transition-base)}.device-item:hover{background:var(--item-bg-hover);transform:translateY(-2px);border-color:#0071e34d;box-shadow:0 8px 20px -5px #0071e326,0 0 0 1px #0071e31a inset}.device-icon{width:48px;height:48px;margin-right:16px;display:flex;justify-content:center;align-items:center;border-radius:50%;background:var(--icon-bg);border:1px solid var(--item-border);transition:var(--transition-base)}.device-info{flex:1}.device-status-beacon{position:absolute;top:50%;right:22px;width:14px;height:14px;transform:translateY(-50%);pointer-events:none}.device-status-dot,.device-status-ring{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:50%}.device-status-dot{top:3px;right:3px;bottom:3px;left:3px;background:var(--accent-blue);box-shadow:0 0 0 1px #ffffffbf,0 0 14px #0a84ff47}.device-status-ring{border:2px solid rgba(10,132,255,.22);opacity:0;transform:scale(.7);animation:deviceBeaconPing 2s infinite cubic-bezier(.16,.84,.44,1)}.device-status-ring-delay{animation-delay:1s}.device-icon .app-icon{position:relative;z-index:1;color:var(--text-main)}.device-item:hover .device-icon{background:var(--accent-cyan-light);border-color:transparent}.device-item:hover .device-icon .app-icon{color:var(--accent-blue)}.device-item:hover .device-status-dot{box-shadow:0 0 0 1px #ffffffd9,0 0 18px #0a84ff5c}.device-item:hover .device-status-ring{border-color:#0a84ff4d;animation-duration:1.4s}.device-info h4{margin-bottom:4px;font-size:16px;font-weight:600;color:var(--text-main)}.device-info p{font-size:13px;color:var(--text-secondary)}.room-action-area{flex:1;display:flex;flex-direction:column;justify-content:center}.btn-create{width:100%;margin-bottom:25px;padding:18px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--item-bg);color:var(--accent-blue);border:1px solid var(--item-border);border-radius:var(--border-radius-sm);box-shadow:0 2px 8px #00000005;font-size:16px;font-weight:600;cursor:pointer;transition:var(--transition-base)}.btn-create:hover{background:var(--item-bg-hover);border-color:#0071e34d;transform:translateY(-2px);box-shadow:0 8px 20px -5px #0071e326}.divider{position:relative;margin-bottom:25px;text-align:center;color:var(--divider-color);font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:1px}.divider:before,.divider:after{content:"";position:absolute;top:50%;width:35%;height:1px;background:var(--divider-line)}.divider:before{left:0}.divider:after{right:0}.room-input-group{display:flex;flex-direction:column;gap:12px}.pending-downloads{margin-top:22px;padding:16px 10px 16px 16px;border:1px solid var(--item-border);border-radius:var(--border-radius-sm);background:var(--item-bg);max-height:236px;overflow-y:auto}.pending-downloads::-webkit-scrollbar{width:6px}.pending-downloads::-webkit-scrollbar-thumb{background:var(--divider-color);border-radius:999px}.pending-downloads-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;font-size:12px;font-weight:700;letter-spacing:.08em;color:var(--text-secondary);position:sticky;top:0;z-index:1;background:var(--item-bg);padding-bottom:8px}.pending-download-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-radius:16px;background:var(--input-bg);border:1px solid var(--item-border);text-decoration:none;transition:var(--transition-base)}.pending-download-item+.pending-download-item{margin-top:10px}.pending-download-item:hover{background:var(--item-bg-hover);border-color:#0071e340;color:var(--accent-blue)}.pending-download-copy{min-width:0}.pending-download-copy strong{display:block;margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:600;color:var(--text-main)}.pending-download-copy p{font-size:12px;color:var(--text-secondary)}input.room-code,.text-input-group input,.text-input-group textarea{width:100%;min-width:0;padding:18px;border:1px solid var(--item-border);border-radius:var(--border-radius-sm);background:var(--input-bg);color:var(--text-main);box-shadow:inset 0 2px 4px #00000005;transition:var(--transition-base)}input.room-code{font-size:24px;font-weight:700;text-align:center;letter-spacing:8px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}input.room-code:focus,.text-input-group input:focus,.text-input-group textarea:focus{background:var(--item-bg);border-color:var(--accent-blue);box-shadow:0 0 0 3px var(--accent-cyan-light)}input.room-code::placeholder{letter-spacing:normal;font-size:16px;font-weight:400;color:var(--text-secondary);opacity:.6}.btn-primary{width:100%;padding:18px;background:var(--accent-blue);color:#fff;border-radius:var(--border-radius-sm);box-shadow:0 4px 12px #0071e34d;font-size:16px;font-weight:600;cursor:pointer;transition:var(--transition-base)}.btn-primary:hover,.text-input-group button:hover{background:#06c;transform:translateY(-2px);box-shadow:0 6px 16px #0071e366}.huge-code{margin:15px 0 25px;padding:15px 30px;font-size:56px;font-weight:800;color:var(--accent-blue);letter-spacing:10px;font-family:ui-monospace,monospace;border-radius:20px;border:1px solid rgba(0,113,227,.2);background:var(--accent-cyan-light)}.spinner{width:28px;height:28px;margin:0 auto 15px;border-radius:50%;border:3px solid var(--item-border);border-top-color:var(--accent-blue);animation:spin .8s linear infinite}.waiting-tip{font-size:14px;font-weight:500;color:var(--accent-blue)}.btn-cancel{margin-top:25px;background:none;color:var(--text-secondary);font-size:14px;cursor:pointer;transition:var(--transition-base)}.btn-cancel:hover{color:var(--text-main)}.transfer-panel,.admin-panel{width:100%;margin:0 auto;opacity:0;transform:scale(.95);animation:showPanelPro .5s forwards cubic-bezier(.2,.8,.2,1)}.transfer-panel{max-width:650px}.transfer-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px;padding-bottom:20px;border-bottom:1px solid var(--divider-line)}.connected-to h2{margin-bottom:4px;font-size:20px;font-weight:700;color:var(--text-main)}.connected-to p{display:flex;align-items:center;gap:6px;font-size:13px;font-weight:500;color:var(--success-green)}.close-btn{width:36px;height:36px;display:flex;justify-content:center;align-items:center;border-radius:50%;background:var(--icon-bg);border:1px solid var(--item-border);color:var(--text-secondary);cursor:pointer;transition:var(--transition-base)}.close-btn:hover{background:var(--item-bg-hover);color:var(--text-main);transform:rotate(90deg)}.drop-zone{padding:40px 30px;margin-bottom:20px;text-align:center;background:var(--input-bg);border:2px dashed var(--divider-color);border-radius:var(--border-radius-sm);cursor:pointer;transition:var(--transition-base)}.drop-zone:hover,.drop-zone-active{border-color:var(--accent-blue);background-color:var(--accent-cyan-light)}.drop-zone-icon{margin-bottom:12px;font-size:42px;color:var(--text-secondary);transition:var(--transition-base)}.drop-zone:hover .drop-zone-icon,.drop-zone-active .drop-zone-icon{transform:translateY(-5px);color:var(--accent-blue)}.drop-zone-text{font-size:15px;font-weight:500;color:var(--text-main)}.text-input-group{display:flex;gap:12px;margin-bottom:20px}.text-input-group input{flex:1;padding:16px 20px;font-size:15px}.text-input-group button{width:52px;display:flex;align-items:center;justify-content:center;border-radius:var(--border-radius-sm);background:var(--accent-blue);color:#fff;box-shadow:0 4px 12px #0071e34d;cursor:pointer;transition:var(--transition-base)}.batch-actions{display:flex;justify-content:flex-end;margin-bottom:12px;height:0;overflow:hidden;opacity:0;pointer-events:none;transition:var(--transition-base)}.batch-actions.active{height:36px;overflow:visible;opacity:1;pointer-events:auto}.btn-small-primary{display:flex;align-items:center;gap:6px;padding:8px 16px;background:var(--item-bg);color:var(--text-main);border:1px solid var(--item-border);border-radius:12px;box-shadow:0 2px 6px #0000000a;font-size:13px;font-weight:600;cursor:pointer;transition:var(--transition-base)}.btn-small-primary:hover{background:var(--item-bg-hover);color:var(--accent-blue);border-color:#0071e34d;transform:translateY(-1px)}.batch-progress-container{max-height:280px;overflow-y:auto;padding-right:8px;padding-bottom:10px;text-align:left}.batch-progress-container::-webkit-scrollbar{width:6px}.batch-progress-container::-webkit-scrollbar-thumb{background:var(--divider-color);border-radius:4px}.batch-item{margin-bottom:12px;padding:16px;background:var(--item-bg);border:1px solid var(--item-border);border-radius:var(--border-radius-sm);box-shadow:0 2px 8px #00000005;transition:all .3s ease}.batch-item:hover{border-color:#00000014;box-shadow:0 4px 12px #0000000a}body[data-theme=dark] .batch-item:hover{border-color:#ffffff1a}.file-info{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;font-size:14px}.file-info-left{max-width:60%;display:flex;align-items:center;gap:10px;overflow:hidden}.file-icon-wrapper{width:32px;height:32px;display:flex;justify-content:center;align-items:center;border-radius:8px;background:var(--icon-bg);color:var(--accent-blue)}.file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:var(--text-main)}.file-info-right{display:flex;align-items:center;gap:8px}.file-status{margin-right:4px;white-space:nowrap;font-size:12px;font-weight:500;font-variant-numeric:tabular-nums}.action-btn{width:28px;height:28px;display:flex;justify-content:center;align-items:center;border-radius:8px;background:transparent;border:1px solid transparent;color:var(--text-secondary);cursor:pointer;transition:var(--transition-base)}.action-btn:hover{color:var(--text-main);background:var(--icon-bg);border-color:var(--item-border)}.action-btn.primary:hover{color:var(--accent-blue);background:var(--accent-cyan-light)}.action-btn.danger:hover{color:var(--danger-red);background:#ff3b301a}.progress-bar-container{height:6px;overflow:hidden;background:var(--icon-bg);border-radius:4px}.progress-bar-fill{height:100%;width:0;background:var(--accent-blue);border-radius:4px;transition:width .1s linear}.progress-bar-fill.success{background:var(--success-green)}.admin-header-card{margin-bottom:30px;padding:30px}.transfer-head-compact{margin-bottom:0;padding-bottom:0;border-bottom:0}.admin-title{font-size:24px;color:var(--text-main)}.connected-to .admin-subtitle{display:block;margin-top:4px;color:var(--text-secondary)}.admin-summary-grid{margin-bottom:30px}.admin-stats-card{height:100%}.admin-stats-panel{display:flex;flex-direction:column;height:100%}.admin-stats-row{flex:1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));gap:12px}.admin-stat-item{display:flex;flex-direction:column;justify-content:space-between;min-height:0;padding:16px 15px;text-align:left;border:1px solid var(--item-border);border-radius:20px;background:var(--item-bg);box-shadow:0 8px 24px -18px #0c2b6433}.admin-stat-item-fluid{grid-column:1 / -1;min-height:142px;padding:0;border:0;background:transparent;box-shadow:none}.admin-fluid-card{position:relative;isolation:isolate;display:flex;height:100%;min-height:0;overflow:hidden;padding:0;border:1px solid rgba(123,166,255,.5);border-radius:16px;background-color:#edf1f9;box-shadow:0 8px 24px #0000000a}.admin-fluid-fill{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}.admin-fluid-wave{position:absolute;left:50%;top:calc(100% - var(--fluid-level));width:2500px;height:2500px;transform:translate(-50%);transform-origin:50% 50%;transition:top .8s cubic-bezier(.4,0,.2,1)}.admin-fluid-wave-a{background-color:#afc8f599;border-radius:48.5%;animation:fluidSpin 20s linear infinite}.admin-fluid-wave-b{background-color:#91b2ebe0;border-radius:49%;animation:fluidSpin 25s linear infinite;animation-delay:-10s}.admin-fluid-content{position:relative;z-index:1;display:flex;align-items:center;gap:18px;width:100%;min-height:110px;padding:24px}.admin-fluid-icon{width:52px;height:52px;display:flex;align-items:center;justify-content:center;flex:none;border-radius:14px;background-color:#d7e1f4;color:#3b60c4;box-shadow:none}.admin-fluid-copy{min-width:0;flex:1}.admin-fluid-copy h3{margin:0 0 8px;font-size:22px;font-weight:600;line-height:1.15;color:#333}.admin-fluid-copy p{margin:0;font-size:13px;color:#778299}.admin-fluid-copy small{display:block;margin-top:10px;font-size:12px;color:var(--text-secondary)}.admin-stat-kicker{font-size:11px;font-weight:700;letter-spacing:.08em;color:var(--text-secondary)}.admin-stat-item h3{margin-top:10px;margin-bottom:6px;font-size:34px;font-weight:800;font-family:ui-monospace,monospace;line-height:1}.admin-stat-item p{font-size:13px;font-weight:500;color:var(--text-secondary)}.stat-suffix{font-size:16px}.admin-config-stack{display:flex;flex-direction:column;height:100%;gap:15px}.admin-config-card{height:100%}.admin-config-row{margin-bottom:0}.admin-config-row-field{flex-direction:column;align-items:stretch}.admin-config-row-last{margin-bottom:0}.admin-field-meta{display:flex;flex-direction:column;gap:6px}.admin-field-label{font-size:13px;font-weight:700;letter-spacing:.04em;color:var(--text-main)}.admin-field-hint{font-size:12px;line-height:1.5;color:var(--text-secondary)}.admin-field-control-row{display:flex;gap:12px}.admin-field-control-row input{flex:1}.admin-config-insights{display:flex;flex:1;flex-direction:column;gap:14px}.admin-config-highlight{padding:18px 20px;border:1px solid var(--item-border);border-radius:22px;background:linear-gradient(145deg,var(--item-bg-hover),var(--item-bg));box-shadow:0 16px 30px -24px #0c2b6459}.admin-config-badge{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:var(--accent-cyan-light);color:var(--accent-blue);font-size:11px;font-weight:700;letter-spacing:.12em}.admin-config-highlight h3{margin-top:14px;margin-bottom:8px;font-size:30px;font-weight:800;font-family:ui-monospace,monospace;color:var(--text-main)}.admin-config-highlight p{font-size:13px;line-height:1.7;color:var(--text-secondary)}.admin-table-card{min-height:0}.admin-table-wrapper{max-height:420px;overflow-x:auto;overflow-y:auto;border-radius:18px}.admin-table-wrapper::-webkit-scrollbar{width:8px;height:8px}.admin-table-wrapper::-webkit-scrollbar-thumb{background:var(--divider-color);border-radius:999px}.admin-table{width:100%;margin-top:10px;border-collapse:collapse;font-size:14px;text-align:left}.admin-table th{position:sticky;top:0;z-index:1;padding:14px 12px;color:var(--text-secondary);border-bottom:1px solid var(--divider-line);background:var(--card-bg);font-weight:600;white-space:nowrap}.admin-table td{padding:14px 12px;color:var(--text-main);border-bottom:1px solid var(--item-border)}.admin-table tbody tr:last-child td{border-bottom:0}.admin-table tr:hover td{background-color:var(--item-bg-hover)}.hidden{display:none!important}.footer{position:absolute;right:0;bottom:24px;left:0;text-align:center;font-size:13px;color:var(--text-secondary);opacity:.6;line-height:1.8;letter-spacing:.5px;transition:var(--transition-base)}.footer:hover{opacity:1}.footer .divider-line{margin:0 8px;opacity:.5}.footer a{text-decoration:none}.footer a:hover{color:var(--text-main)}#admin-trigger{cursor:pointer;-webkit-user-select:none;user-select:none}@keyframes backgroundMove{0%{transform:scale(1) translate(0)}to{transform:scale(1.05) translate(2%,2%)}}@keyframes ripplePro{0%{transform:scale(.8);opacity:0}20%{opacity:.5}to{transform:scale(3);opacity:0}}@keyframes deviceBeaconPing{0%{opacity:0;transform:scale(.7)}22%{opacity:.9}to{opacity:0;transform:scale(2.8)}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes showPanelPro{to{opacity:1;transform:scale(1)}}@keyframes fluidSpin{0%{transform:translate(-50%) rotate(0)}to{transform:translate(-50%) rotate(360deg)}}@media (min-width: 768px){.main-grid{grid-template-columns:1fr 1fr;gap:40px}}@media (min-width: 1024px){.container{max-width:1000px;padding:40px 0}.header h1{font-size:48px}.card{padding:40px}.btn-create,.btn-primary,input.room-code{padding:18px}.transfer-panel{max-width:700px}}@media (max-width: 767px){.admin-stats-row{grid-template-columns:1fr;grid-template-rows:none}.file-info,.file-info-right{align-items:flex-start}.file-info{flex-direction:column}.file-info-left,.file-info-right{max-width:100%;width:100%}.file-info-right{justify-content:flex-end}}@media (max-width: 600px){body{padding-inline:16px}.theme-toggle{position:fixed;top:auto;right:20px;bottom:30px;width:50px;height:50px;margin-top:0;box-shadow:0 10px 30px #00000026}.card,.admin-header-card{padding:28px 22px}.header{margin-bottom:28px}.header h1{font-size:36px}.huge-code{width:100%;font-size:44px;letter-spacing:8px}.text-input-group{flex-direction:column}.text-input-group button{width:100%;min-height:52px}} diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..3e17b33 --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,13 @@ + + + + + + AirShare Pro + + + + +
+ + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d24218e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + AirShare Pro + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fef7f51 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1130 @@ +{ + "name": "airshare-pro-vue", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "airshare-pro-vue", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..726a522 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "airshare-pro-vue", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^5.4.10" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f3a9cb7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,2737 @@ + + + diff --git a/frontend/src/api/admin.js b/frontend/src/api/admin.js new file mode 100644 index 0000000..3a810d1 --- /dev/null +++ b/frontend/src/api/admin.js @@ -0,0 +1,28 @@ +import { http, withBearerToken } from './http' + +function withAdminAuth(token) { + return { + headers: withBearerToken(token), + } +} + +export const adminApi = { + login(username, password) { + return http.post('/api/admin/login', { + username, + password, + }) + }, + stats(token) { + return http.get('/api/admin/stats', withAdminAuth(token)) + }, + config(token) { + return http.get('/api/admin/config', withAdminAuth(token)) + }, + updateConfig(token, input) { + return http.put('/api/admin/config', input, withAdminAuth(token)) + }, + recentTransfers(token) { + return http.get('/api/admin/transfers/recent', withAdminAuth(token)) + }, +} diff --git a/frontend/src/api/devices.js b/frontend/src/api/devices.js new file mode 100644 index 0000000..954ecc4 --- /dev/null +++ b/frontend/src/api/devices.js @@ -0,0 +1,18 @@ +import { http } from './http' + +export const devicesApi = { + register(input) { + return http.post('/api/devices/register', input) + }, + heartbeat(deviceId) { + return http.post('/api/devices/heartbeat', { device_id: deviceId }) + }, + listCandidates(deviceId) { + return http.get('/api/devices/candidates', { + query: { deviceId }, + }) + }, + listPendingDownloads(deviceId) { + return http.get(`/api/devices/${encodeURIComponent(deviceId)}/pending-downloads`) + }, +} diff --git a/frontend/src/api/http.js b/frontend/src/api/http.js new file mode 100644 index 0000000..4aef0a1 --- /dev/null +++ b/frontend/src/api/http.js @@ -0,0 +1,94 @@ +let deviceSession = { + deviceId: '', + token: '', +} + +function buildDeviceHeaders() { + if (!deviceSession.deviceId || !deviceSession.token) { + return {} + } + + return { + 'X-Device-ID': deviceSession.deviceId, + 'X-Device-Token': deviceSession.token, + } +} + +function buildHeaders(headers = {}, hasBody = false) { + return { + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + ...buildDeviceHeaders(), + ...headers, + } +} + +function buildUrl(path, query) { + if (!query || Object.keys(query).length === 0) { + return path + } + + const params = new URLSearchParams() + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + params.set(key, String(value)) + } + }) + + const queryString = params.toString() + return queryString ? `${path}?${queryString}` : path +} + +async function request(path, options = {}) { + const hasBody = options.body !== undefined + const response = await fetch(buildUrl(path, options.query), { + method: options.method || 'GET', + headers: buildHeaders(options.headers, hasBody), + body: hasBody ? JSON.stringify(options.body) : undefined, + }) + + const payload = await response.json().catch(() => ({})) + + if (!response.ok) { + const error = new Error(payload.error || `Request failed: ${response.status}`) + error.status = response.status + throw error + } + + return payload.data +} + +export const http = { + get(path, options = {}) { + return request(path, { ...options, method: 'GET' }) + }, + post(path, body, options = {}) { + return request(path, { ...options, method: 'POST', body }) + }, + put(path, body, options = {}) { + return request(path, { ...options, method: 'PUT', body }) + }, + patch(path, body, options = {}) { + return request(path, { ...options, method: 'PATCH', body }) + }, +} + +export function setDeviceSession(deviceId, token) { + deviceSession = { + deviceId: deviceId || '', + token: token || '', + } +} + +export function clearDeviceSession() { + setDeviceSession('', '') +} + +export function getDeviceSessionHeaders() { + return buildDeviceHeaders() +} + +export function withBearerToken(token) { + return { + Authorization: `Bearer ${token}`, + } +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..d810b9b --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,5 @@ +export { adminApi } from './admin' +export { devicesApi } from './devices' +export { roomsApi } from './rooms' +export { runtimeApi } from './runtime' +export { transfersApi } from './transfers' diff --git a/frontend/src/api/rooms.js b/frontend/src/api/rooms.js new file mode 100644 index 0000000..05c8172 --- /dev/null +++ b/frontend/src/api/rooms.js @@ -0,0 +1,23 @@ +import { http } from './http' + +export const roomsApi = { + create(creatorDeviceId) { + return http.post('/api/rooms', { + creator_device_id: creatorDeviceId, + }) + }, + get(code) { + return http.get(`/api/rooms/${encodeURIComponent(code)}`) + }, + join(code, joinerDeviceId) { + return http.post('/api/rooms/join', { + code, + joiner_device_id: joinerDeviceId, + }) + }, + cancel(code, requesterId) { + return http.post(`/api/rooms/${encodeURIComponent(code)}/cancel`, { + requester_id: requesterId, + }) + }, +} diff --git a/frontend/src/api/runtime.js b/frontend/src/api/runtime.js new file mode 100644 index 0000000..8d1a29f --- /dev/null +++ b/frontend/src/api/runtime.js @@ -0,0 +1,7 @@ +import { http } from './http' + +export const runtimeApi = { + config() { + return http.get('/api/runtime/config') + }, +} diff --git a/frontend/src/api/transfers.js b/frontend/src/api/transfers.js new file mode 100644 index 0000000..3224005 --- /dev/null +++ b/frontend/src/api/transfers.js @@ -0,0 +1,55 @@ +import { getDeviceSessionHeaders, http } from './http' + +export const transfersApi = { + create(input) { + return http.post('/api/transfers', input) + }, + presignFallback(id) { + return http.post(`/api/transfers/${encodeURIComponent(id)}/fallback/presign`, {}) + }, + uploadFallback(id, file, onProgress) { + return uploadFile(`/api/transfers/${encodeURIComponent(id)}/fallback/upload`, file, onProgress) + }, + updateStatus(id, input) { + return http.patch(`/api/transfers/${encodeURIComponent(id)}/status`, input) + }, +} + +function uploadFile(url, file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('PUT', url) + xhr.responseType = 'json' + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') + Object.entries(getDeviceSessionHeaders()).forEach(([key, value]) => { + xhr.setRequestHeader(key, value) + }) + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable || typeof onProgress !== 'function') { + return + } + onProgress(Math.round((event.loaded / event.total) * 100)) + } + + xhr.onload = () => { + const payload = xhr.response || safeParseJson(xhr.responseText) + if (xhr.status >= 200 && xhr.status < 300) { + resolve(payload.data) + return + } + reject(new Error(payload?.error || `Upload failed: ${xhr.status}`)) + } + + xhr.onerror = () => reject(new Error('Upload failed')) + xhr.send(file) + }) +} + +function safeParseJson(value) { + try { + return JSON.parse(value) + } catch { + return null + } +} diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue new file mode 100644 index 0000000..fbb93df --- /dev/null +++ b/frontend/src/components/AdminPanel.vue @@ -0,0 +1,219 @@ + + + diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue new file mode 100644 index 0000000..e0a9b31 --- /dev/null +++ b/frontend/src/components/AppFooter.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..17e2d31 --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/DeviceDiscoveryCard.vue b/frontend/src/components/DeviceDiscoveryCard.vue new file mode 100644 index 0000000..123b26c --- /dev/null +++ b/frontend/src/components/DeviceDiscoveryCard.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/components/GlassCard.vue b/frontend/src/components/GlassCard.vue new file mode 100644 index 0000000..5e07c30 --- /dev/null +++ b/frontend/src/components/GlassCard.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/LocalIcon.vue b/frontend/src/components/LocalIcon.vue new file mode 100644 index 0000000..2732d19 --- /dev/null +++ b/frontend/src/components/LocalIcon.vue @@ -0,0 +1,173 @@ + + + diff --git a/frontend/src/components/RemoteRoomCard.vue b/frontend/src/components/RemoteRoomCard.vue new file mode 100644 index 0000000..43a2558 --- /dev/null +++ b/frontend/src/components/RemoteRoomCard.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/components/TransferPanel.vue b/frontend/src/components/TransferPanel.vue new file mode 100644 index 0000000..a334338 --- /dev/null +++ b/frontend/src/components/TransferPanel.vue @@ -0,0 +1,138 @@ + + + diff --git a/frontend/src/components/TransferQueueItem.vue b/frontend/src/components/TransferQueueItem.vue new file mode 100644 index 0000000..184fb63 --- /dev/null +++ b/frontend/src/components/TransferQueueItem.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/data/mock.js b/frontend/src/data/mock.js new file mode 100644 index 0000000..35e7026 --- /dev/null +++ b/frontend/src/data/mock.js @@ -0,0 +1,65 @@ +export const discoveredDevices = [ + { + id: 'iphone-15-pro', + name: 'iPhone 15 Pro', + description: '局域网在线', + icon: 'smartphone', + connectionType: '局域网直连', + }, + { + id: 'macbook-pro', + name: 'MacBook Pro', + description: '局域网在线', + icon: 'laptop_mac', + connectionType: '局域网直连', + }, +] + +export const adminStats = [ + { label: '今日请求(次)', value: '3,421', tone: 'blue' }, + { label: '中继流量', value: '15.2', suffix: 'G', tone: 'cyan' }, + { label: '活跃设备', value: '42', tone: 'default' }, +] + +export const transferRecords = [ + { + time: '2 分钟前', + peer: 'iPhone 15 Pro (192.168.1.5)', + type: '图片 (3张)', + size: '12.5 MB', + status: '直连成功', + tone: 'success', + }, + { + time: '15 分钟前', + peer: 'MacBook Pro (192.168.1.8)', + type: '压缩包 (.zip)', + size: '1.2 GB', + status: '直连成功', + tone: 'success', + }, + { + time: '1 小时前', + peer: 'Windows PC (Remote)', + type: '视频 (.mp4)', + size: '450 MB', + status: '中继成功', + tone: 'primary', + }, + { + time: '3 小时前', + peer: 'Android Device (Remote)', + type: '文本消息', + size: '< 1 KB', + status: '中继成功', + tone: 'primary', + }, + { + time: '昨天 23:15', + peer: 'Unknown IP (114.x.x.x)', + type: '大文件 (.iso)', + size: '4.5 GB', + status: '连接中断', + tone: 'danger', + }, +] diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fdbdce5 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles.css' + +createApp(App).mount('#app') diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..6ef31e7 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,1496 @@ +:root { + --bg-color: #09090b; + --card-bg: rgba(24, 24, 27, 0.65); + --card-border: rgba(255, 255, 255, 0.08); + --card-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.5), 0 0 2px rgba(255, 255, 255, 0.05) inset; + --text-main: #f4f4f5; + --text-secondary: #a1a1aa; + --item-bg: rgba(255, 255, 255, 0.03); + --item-bg-hover: rgba(255, 255, 255, 0.06); + --item-border: rgba(255, 255, 255, 0.05); + --icon-bg: rgba(255, 255, 255, 0.05); + --input-bg: rgba(0, 0, 0, 0.3); + --divider-color: rgba(255, 255, 255, 0.15); + --divider-line: rgba(255, 255, 255, 0.08); + --glow-1: rgba(0, 113, 227, 0.15); + --glow-2: rgba(34, 211, 238, 0.12); + --glow-3: rgba(168, 85, 247, 0.1); + --accent-blue: #0a84ff; + --accent-cyan: #30d158; + --accent-cyan-light: rgba(10, 132, 255, 0.2); + --success-green: #30d158; + --danger-red: #ff453a; + --border-radius-lg: 28px; + --border-radius-sm: 18px; + --transition-base: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); + --font-stack: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", Roboto, sans-serif; +} + +body[data-theme="light"] { + --bg-color: #f2f4f8; + --card-bg: rgba(255, 255, 255, 0.85); + --card-border: rgba(255, 255, 255, 1); + --card-shadow: 0 10px 30px -5px rgba(12, 43, 100, 0.08), 0 4px 10px -3px rgba(12, 43, 100, 0.04), + 0 0 1px rgba(0, 0, 0, 0.05); + --text-main: #1d1d1f; + --text-secondary: #86868b; + --item-bg: #ffffff; + --item-bg-hover: #f8fafd; + --item-border: rgba(0, 0, 0, 0.04); + --icon-bg: #f5f5f7; + --input-bg: #ffffff; + --divider-color: #86868b; + --divider-line: #e5e5ea; + --glow-1: rgba(0, 113, 227, 0.05); + --glow-2: rgba(34, 211, 238, 0.05); + --glow-3: rgba(168, 85, 247, 0.03); + --accent-blue: #0071e3; + --accent-cyan: #0071e3; + --accent-cyan-light: rgba(0, 113, 227, 0.1); + --success-green: #34c759; + --danger-red: #ff3b30; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + outline: none; +} + +html, +body, +#app { + min-height: 100%; +} + +body { + font-family: var(--font-stack); + background-color: var(--bg-color); + color: var(--text-main); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 20px 20px 100px; + position: relative; + overflow-x: hidden; + overflow-y: auto; + transition: background-color 0.5s ease, color 0.5s ease; + -webkit-font-smoothing: antialiased; +} + +body::before { + content: ""; + position: fixed; + inset: -25%; + width: 150%; + height: 150%; + z-index: -1; + background-image: radial-gradient(circle at 20% 20%, var(--glow-1) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, var(--glow-2) 0%, transparent 40%), + radial-gradient(circle at 50% 80%, var(--glow-3) 0%, transparent 60%); + animation: backgroundMove 25s infinite alternate ease-in-out; +} + +body, +button, +input { + font: inherit; +} + +button { + border: 0; + color: inherit; +} + +a { + color: inherit; +} + +.app-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; +} + +.app-icon svg { + width: 100%; + height: 100%; + overflow: visible; +} + +.container { + width: 100%; + max-width: 1000px; + margin: auto; + position: relative; + z-index: 1; + transition: var(--transition-base); +} + +.header { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 80px; + margin-top: 30px; + margin-bottom: 40px; + text-align: center; +} + +.header h1 { + font-size: 42px; + font-weight: 800; + letter-spacing: -1px; + margin-bottom: 8px; +} + +.header p { + color: var(--text-secondary); + font-size: 16px; + font-weight: 400; + letter-spacing: 0.5px; +} + +.theme-toggle { + position: absolute; + top: 50%; + right: 0; + width: 44px; + height: 44px; + margin-top: -22px; + border-radius: 50%; + background: var(--card-bg); + border: 1px solid var(--card-border); + color: var(--text-main); + box-shadow: var(--card-shadow); + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + transition: var(--transition-base); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.theme-toggle:hover { + transform: scale(1.08); + box-shadow: 0 8px 25px rgba(0, 113, 227, 0.15); +} + +.main-grid { + display: grid; + grid-template-columns: 1fr; + gap: 30px; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + padding: 40px 30px; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: var(--border-radius-lg); + box-shadow: var(--card-shadow); + transition: var(--transition-base); + backdrop-filter: blur(40px) saturate(150%); + -webkit-backdrop-filter: blur(40px) saturate(150%); +} + +.card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; + opacity: 0.5; + background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); +} + +.section-title { + margin-bottom: 25px; + text-align: center; + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 2px; +} + +.local-device-name { + margin-top: -10px; + margin-bottom: 18px; + text-align: center; + font-size: 13px; + color: var(--text-secondary); +} + +.local-device-name strong { + color: var(--text-main); + font-weight: 600; +} + +.radar-container, +.waiting-area { + flex: 1; + min-height: 200px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +.radar { + position: relative; + width: 80px; + height: 80px; + margin-bottom: 25px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background: var(--accent-cyan-light); + border: 1px solid rgba(0, 113, 227, 0.1); + box-shadow: 0 0 20px var(--accent-cyan-light); +} + +.radar::before, +.radar::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + border: 2px solid var(--accent-blue); + opacity: 0; + animation: ripplePro 3s infinite cubic-bezier(0.1, 0.8, 0.3, 1); +} + +.radar::before { + animation-delay: 1.5s; +} + +.radar-icon { + font-size: 36px; + color: var(--accent-blue); +} + +.scan-status, +.waiting-subtitle { + margin-top: 10px; + font-size: 14px; + color: var(--text-secondary); +} + +.device-list { + width: 100%; + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; +} + +.device-item { + position: relative; + width: 100%; + display: flex; + align-items: center; + padding: 16px 20px; + background: var(--item-bg); + border: 1px solid var(--item-border); + border-radius: var(--border-radius-sm); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); + cursor: pointer; + color: var(--text-main); + text-align: left; + transition: var(--transition-base); +} + +.device-item:hover { + background: var(--item-bg-hover); + transform: translateY(-2px); + border-color: rgba(0, 113, 227, 0.3); + box-shadow: 0 8px 20px -5px rgba(0, 113, 227, 0.15), 0 0 0 1px rgba(0, 113, 227, 0.1) inset; +} + +.device-icon { + width: 48px; + height: 48px; + margin-right: 16px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background: var(--icon-bg); + border: 1px solid var(--item-border); + transition: var(--transition-base); +} + +.device-info { + flex: 1; +} + +.device-status-beacon { + position: absolute; + top: 50%; + right: 22px; + width: 14px; + height: 14px; + transform: translateY(-50%); + pointer-events: none; +} + +.device-status-dot, +.device-status-ring { + position: absolute; + inset: 0; + border-radius: 50%; +} + +.device-status-dot { + inset: 3px; + background: var(--accent-blue); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.75), 0 0 14px rgba(10, 132, 255, 0.28); +} + +.device-status-ring { + border: 2px solid rgba(10, 132, 255, 0.22); + opacity: 0; + transform: scale(0.7); + animation: deviceBeaconPing 2s infinite cubic-bezier(0.16, 0.84, 0.44, 1); +} + +.device-status-ring-delay { + animation-delay: 1s; +} + +.device-icon .app-icon { + position: relative; + z-index: 1; + color: var(--text-main); +} + +.device-item:hover .device-icon { + background: var(--accent-cyan-light); + border-color: transparent; +} + +.device-item:hover .device-icon .app-icon { + color: var(--accent-blue); +} + +.device-item:hover .device-status-dot { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 18px rgba(10, 132, 255, 0.36); +} + +.device-item:hover .device-status-ring { + border-color: rgba(10, 132, 255, 0.3); + animation-duration: 1.4s; +} + +.device-info h4 { + margin-bottom: 4px; + font-size: 16px; + font-weight: 600; + color: var(--text-main); +} + +.device-info p { + font-size: 13px; + color: var(--text-secondary); +} + +.room-action-area { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.btn-create { + width: 100%; + margin-bottom: 25px; + padding: 18px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--item-bg); + color: var(--accent-blue); + border: 1px solid var(--item-border); + border-radius: var(--border-radius-sm); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: var(--transition-base); +} + +.btn-create:hover { + background: var(--item-bg-hover); + border-color: rgba(0, 113, 227, 0.3); + transform: translateY(-2px); + box-shadow: 0 8px 20px -5px rgba(0, 113, 227, 0.15); +} + +.divider { + position: relative; + margin-bottom: 25px; + text-align: center; + color: var(--divider-color); + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 1px; +} + +.divider::before, +.divider::after { + content: ""; + position: absolute; + top: 50%; + width: 35%; + height: 1px; + background: var(--divider-line); +} + +.divider::before { + left: 0; +} + +.divider::after { + right: 0; +} + +.room-input-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.pending-downloads { + margin-top: 22px; + padding: 16px; + border: 1px solid var(--item-border); + border-radius: var(--border-radius-sm); + background: var(--item-bg); + max-height: 236px; + overflow-y: auto; + padding-right: 10px; +} + +.pending-downloads::-webkit-scrollbar { + width: 6px; +} + +.pending-downloads::-webkit-scrollbar-thumb { + background: var(--divider-color); + border-radius: 999px; +} + +.pending-downloads-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-secondary); + position: sticky; + top: 0; + z-index: 1; + background: var(--item-bg); + padding-bottom: 8px; +} + +.pending-download-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 16px; + background: var(--input-bg); + border: 1px solid var(--item-border); + text-decoration: none; + transition: var(--transition-base); +} + +.pending-download-item + .pending-download-item { + margin-top: 10px; +} + +.pending-download-item:hover { + background: var(--item-bg-hover); + border-color: rgba(0, 113, 227, 0.25); + color: var(--accent-blue); +} + +.pending-download-copy { + min-width: 0; +} + +.pending-download-copy strong { + display: block; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 600; + color: var(--text-main); +} + +.pending-download-copy p { + font-size: 12px; + color: var(--text-secondary); +} + +input.room-code, +.text-input-group input, +.text-input-group textarea { + width: 100%; + min-width: 0; + padding: 18px; + border: 1px solid var(--item-border); + border-radius: var(--border-radius-sm); + background: var(--input-bg); + color: var(--text-main); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02); + transition: var(--transition-base); +} + +input.room-code { + font-size: 24px; + font-weight: 700; + text-align: center; + letter-spacing: 8px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +input.room-code:focus, +.text-input-group input:focus, +.text-input-group textarea:focus { + background: var(--item-bg); + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px var(--accent-cyan-light); +} + +input.room-code::placeholder { + letter-spacing: normal; + font-size: 16px; + font-weight: 400; + color: var(--text-secondary); + opacity: 0.6; +} + +.btn-primary { + width: 100%; + padding: 18px; + background: var(--accent-blue); + color: #fff; + border-radius: var(--border-radius-sm); + box-shadow: 0 4px 12px rgba(0, 113, 227, 0.3); + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: var(--transition-base); +} + +.btn-primary:hover, +.text-input-group button:hover { + background: #0066cc; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 113, 227, 0.4); +} + +.huge-code { + margin: 15px 0 25px; + padding: 15px 30px; + font-size: 56px; + font-weight: 800; + color: var(--accent-blue); + letter-spacing: 10px; + font-family: ui-monospace, monospace; + border-radius: 20px; + border: 1px solid rgba(0, 113, 227, 0.2); + background: var(--accent-cyan-light); +} + +.spinner { + width: 28px; + height: 28px; + margin: 0 auto 15px; + border-radius: 50%; + border: 3px solid var(--item-border); + border-top-color: var(--accent-blue); + animation: spin 0.8s linear infinite; +} + +.waiting-tip { + font-size: 14px; + font-weight: 500; + color: var(--accent-blue); +} + +.btn-cancel { + margin-top: 25px; + background: none; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + transition: var(--transition-base); +} + +.btn-cancel:hover { + color: var(--text-main); +} + +.transfer-panel, +.admin-panel { + width: 100%; + margin: 0 auto; + opacity: 0; + transform: scale(0.95); + animation: showPanelPro 0.5s forwards cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.transfer-panel { + max-width: 650px; +} + +.transfer-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--divider-line); +} + +.connected-to h2 { + margin-bottom: 4px; + font-size: 20px; + font-weight: 700; + color: var(--text-main); +} + +.connected-to p { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--success-green); +} + +.close-btn { + width: 36px; + height: 36px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background: var(--icon-bg); + border: 1px solid var(--item-border); + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition-base); +} + +.close-btn:hover { + background: var(--item-bg-hover); + color: var(--text-main); + transform: rotate(90deg); +} + +.drop-zone { + padding: 40px 30px; + margin-bottom: 20px; + text-align: center; + background: var(--input-bg); + border: 2px dashed var(--divider-color); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: var(--transition-base); +} + +.drop-zone:hover, +.drop-zone-active { + border-color: var(--accent-blue); + background-color: var(--accent-cyan-light); +} + +.drop-zone-icon { + margin-bottom: 12px; + font-size: 42px; + color: var(--text-secondary); + transition: var(--transition-base); +} + +.drop-zone:hover .drop-zone-icon, +.drop-zone-active .drop-zone-icon { + transform: translateY(-5px); + color: var(--accent-blue); +} + +.drop-zone-text { + font-size: 15px; + font-weight: 500; + color: var(--text-main); +} + +.text-input-group { + display: flex; + gap: 12px; + margin-bottom: 20px; +} + +.text-input-group input { + flex: 1; + padding: 16px 20px; + font-size: 15px; +} + +.text-input-group button { + width: 52px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-sm); + background: var(--accent-blue); + color: #fff; + box-shadow: 0 4px 12px rgba(0, 113, 227, 0.3); + cursor: pointer; + transition: var(--transition-base); +} + +.batch-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; + height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: var(--transition-base); +} + +.batch-actions.active { + height: 36px; + overflow: visible; + opacity: 1; + pointer-events: auto; +} + +.btn-small-primary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--item-bg); + color: var(--text-main); + border: 1px solid var(--item-border); + border-radius: 12px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: var(--transition-base); +} + +.btn-small-primary:hover { + background: var(--item-bg-hover); + color: var(--accent-blue); + border-color: rgba(0, 113, 227, 0.3); + transform: translateY(-1px); +} + +.batch-progress-container { + max-height: 280px; + overflow-y: auto; + padding-right: 8px; + padding-bottom: 10px; + text-align: left; +} + +.batch-progress-container::-webkit-scrollbar { + width: 6px; +} + +.batch-progress-container::-webkit-scrollbar-thumb { + background: var(--divider-color); + border-radius: 4px; +} + +.batch-item { + margin-bottom: 12px; + padding: 16px; + background: var(--item-bg); + border: 1px solid var(--item-border); + border-radius: var(--border-radius-sm); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); + transition: all 0.3s ease; +} + +.batch-item:hover { + border-color: rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); +} + +body[data-theme="dark"] .batch-item:hover { + border-color: rgba(255, 255, 255, 0.1); +} + +.file-info { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 12px; + font-size: 14px; +} + +.file-info-left { + max-width: 60%; + display: flex; + align-items: center; + gap: 10px; + overflow: hidden; +} + +.file-icon-wrapper { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + background: var(--icon-bg); + color: var(--accent-blue); +} + +.file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--text-main); +} + +.file-info-right { + display: flex; + align-items: center; + gap: 8px; +} + +.file-status { + margin-right: 4px; + white-space: nowrap; + font-size: 12px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.action-btn { + width: 28px; + height: 28px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition-base); +} + +.action-btn:hover { + color: var(--text-main); + background: var(--icon-bg); + border-color: var(--item-border); +} + +.action-btn.primary:hover { + color: var(--accent-blue); + background: var(--accent-cyan-light); +} + +.action-btn.danger:hover { + color: var(--danger-red); + background: rgba(255, 59, 48, 0.1); +} + +.progress-bar-container { + height: 6px; + overflow: hidden; + background: var(--icon-bg); + border-radius: 4px; +} + +.progress-bar-fill { + height: 100%; + width: 0; + background: var(--accent-blue); + border-radius: 4px; + transition: width 0.1s linear; +} + +.progress-bar-fill.success { + background: var(--success-green); +} + +.admin-header-card { + margin-bottom: 30px; + padding: 30px; +} + +.transfer-head-compact { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 0; +} + +.admin-title { + font-size: 24px; + color: var(--text-main); +} + +.connected-to .admin-subtitle { + display: block; + margin-top: 4px; + color: var(--text-secondary); +} + +.admin-summary-grid { + margin-bottom: 30px; +} + +.admin-stats-card { + height: 100%; +} + +.admin-stats-panel { + display: flex; + flex-direction: column; + height: 100%; +} + +.admin-stats-row { + flex: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.admin-stat-item { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 0; + padding: 16px 15px; + text-align: left; + border: 1px solid var(--item-border); + border-radius: 20px; + background: var(--item-bg); + box-shadow: 0 8px 24px -18px rgba(12, 43, 100, 0.2); +} + +.admin-stat-item-fluid { + grid-column: 1 / -1; + min-height: 142px; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.admin-fluid-card { + position: relative; + isolation: isolate; + display: flex; + height: 100%; + min-height: 0; + overflow: hidden; + padding: 0; + border: 1px solid rgba(123, 166, 255, 0.5); + border-radius: 16px; + background-color: #edf1f9; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04); +} + +.admin-fluid-fill { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.admin-fluid-wave { + position: absolute; + left: 50%; + top: calc(100% - var(--fluid-level)); + width: 2500px; + height: 2500px; + transform: translateX(-50%); + transform-origin: 50% 50%; + transition: top 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +.admin-fluid-wave-a { + background-color: rgba(175, 200, 245, 0.6); + border-radius: 48.5%; + animation: fluidSpin 20s linear infinite; +} + +.admin-fluid-wave-b { + background-color: rgba(145, 178, 235, 0.88); + border-radius: 49%; + animation: fluidSpin 25s linear infinite; + animation-delay: -10s; +} + +.admin-fluid-content { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 18px; + width: 100%; + min-height: 110px; + padding: 24px; +} + +.admin-fluid-icon { + width: 52px; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + flex: none; + border-radius: 14px; + background-color: #d7e1f4; + color: #3b60c4; + box-shadow: none; +} + +.admin-fluid-copy { + min-width: 0; + flex: 1; +} + +.admin-fluid-copy h3 { + margin: 0 0 4px 0; + margin-bottom: 8px; + font-size: 22px; + font-weight: 600; + line-height: 1.15; + color: #333333; +} + +.admin-fluid-copy p { + margin: 0; + font-size: 13px; + color: #778299; +} + +.admin-fluid-copy small { + display: block; + margin-top: 10px; + font-size: 12px; + color: var(--text-secondary); +} + +.admin-stat-kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.admin-stat-item h3 { + margin-top: 10px; + margin-bottom: 6px; + font-size: 34px; + font-weight: 800; + font-family: ui-monospace, monospace; + line-height: 1; +} + +.admin-stat-item p { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.stat-suffix { + font-size: 16px; +} + +.admin-config-stack { + display: flex; + flex-direction: column; + height: 100%; + gap: 15px; +} + +.admin-config-card { + height: 100%; +} + +.admin-config-row { + margin-bottom: 0; +} + +.admin-config-row-field { + flex-direction: column; + align-items: stretch; +} + +.admin-config-row-last { + margin-bottom: 0; +} + +.admin-field-meta { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-field-label { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-main); +} + +.admin-field-hint { + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); +} + +.admin-field-control-row { + display: flex; + gap: 12px; +} + +.admin-field-control-row input { + flex: 1; +} + +.admin-config-insights { + display: flex; + flex: 1; + flex-direction: column; + gap: 14px; +} + +.admin-config-highlight { + padding: 18px 20px; + border: 1px solid var(--item-border); + border-radius: 22px; + background: linear-gradient(145deg, var(--item-bg-hover), var(--item-bg)); + box-shadow: 0 16px 30px -24px rgba(12, 43, 100, 0.35); +} + +.admin-config-badge { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: var(--accent-cyan-light); + color: var(--accent-blue); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; +} + +.admin-config-highlight h3 { + margin-top: 14px; + margin-bottom: 8px; + font-size: 30px; + font-weight: 800; + font-family: ui-monospace, monospace; + color: var(--text-main); +} + +.admin-config-highlight p { + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); +} + +.admin-table-card { + min-height: 0; +} + +.admin-table-wrapper { + max-height: 420px; + overflow-x: auto; + overflow-y: auto; + border-radius: 18px; +} + +.admin-table-wrapper::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.admin-table-wrapper::-webkit-scrollbar-thumb { + background: var(--divider-color); + border-radius: 999px; +} + +.admin-table { + width: 100%; + margin-top: 10px; + border-collapse: collapse; + font-size: 14px; + text-align: left; +} + +.admin-table th { + position: sticky; + top: 0; + z-index: 1; + padding: 14px 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--divider-line); + background: var(--card-bg); + font-weight: 600; + white-space: nowrap; +} + +.admin-table td { + padding: 14px 12px; + color: var(--text-main); + border-bottom: 1px solid var(--item-border); +} + +.admin-table tbody tr:last-child td { + border-bottom: 0; +} + +.admin-table tr:hover td { + background-color: var(--item-bg-hover); +} + +.hidden { + display: none !important; +} + +.footer { + position: absolute; + right: 0; + bottom: 24px; + left: 0; + text-align: center; + font-size: 13px; + color: var(--text-secondary); + opacity: 0.6; + line-height: 1.8; + letter-spacing: 0.5px; + transition: var(--transition-base); +} + +.footer:hover { + opacity: 1; +} + +.footer .divider-line { + margin: 0 8px; + opacity: 0.5; +} + +.footer a { + text-decoration: none; +} + +.footer a:hover { + color: var(--text-main); +} + +#admin-trigger { + cursor: pointer; + user-select: none; +} + +@keyframes backgroundMove { + 0% { + transform: scale(1) translate(0, 0); + } + + 100% { + transform: scale(1.05) translate(2%, 2%); + } +} + +@keyframes ripplePro { + 0% { + transform: scale(0.8); + opacity: 0; + } + + 20% { + opacity: 0.5; + } + + 100% { + transform: scale(3); + opacity: 0; + } +} + +@keyframes deviceBeaconPing { + 0% { + opacity: 0; + transform: scale(0.7); + } + + 22% { + opacity: 0.9; + } + + 100% { + opacity: 0; + transform: scale(2.8); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes showPanelPro { + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes fluidSpin { + 0% { + transform: translateX(-50%) rotate(0deg); + } + + 100% { + transform: translateX(-50%) rotate(360deg); + } +} + +@media (min-width: 768px) { + .main-grid { + grid-template-columns: 1fr 1fr; + gap: 40px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1000px; + padding: 40px 0; + } + + .header h1 { + font-size: 48px; + } + + .card { + padding: 40px; + } + + .btn-create, + .btn-primary, + input.room-code { + padding: 18px; + } + + .transfer-panel { + max-width: 700px; + } +} + +@media (max-width: 767px) { + .admin-stats-row { + grid-template-columns: 1fr; + grid-template-rows: none; + } + + .file-info, + .file-info-right { + align-items: flex-start; + } + + .file-info { + flex-direction: column; + } + + .file-info-left, + .file-info-right { + max-width: 100%; + width: 100%; + } + + .file-info-right { + justify-content: flex-end; + } +} + +@media (max-width: 600px) { + body { + padding-inline: 16px; + } + + .theme-toggle { + position: fixed; + top: auto; + right: 20px; + bottom: 30px; + width: 50px; + height: 50px; + margin-top: 0; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); + } + + .card, + .admin-header-card { + padding: 28px 22px; + } + + .header { + margin-bottom: 28px; + } + + .header h1 { + font-size: 36px; + } + + .huge-code { + width: 100%; + font-size: 44px; + letter-spacing: 8px; + } + + .text-input-group { + flex-direction: column; + } + + .text-input-group button { + width: 100%; + min-height: 52px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..0237098 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +const backendTarget = 'http://127.0.0.1:8080' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': { + target: backendTarget, + changeOrigin: true, + }, + '/healthz': { + target: backendTarget, + changeOrigin: true, + }, + '/ws': { + target: backendTarget.replace('http://', 'ws://'), + ws: true, + changeOrigin: true, + }, + }, + }, +})