From 4f0754ecc52ade76e0356bceba3fa84f68f39e9e Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 5 Jul 2026 17:37:19 -0400 Subject: [PATCH] Add nasctl: Go NAS control plane with React frontend --- .gitignore | 11 + AGENTS.md | 53 + Makefile | 46 + README.md | 97 +- cmd/nasctl/main.go | 101 + go.mod | 22 + go.sum | 51 + internal/db/db.go | 62 + internal/db/migrations/0001_init.sql | 65 + internal/db/models.go | 60 + internal/db/queries.go | 286 +++ internal/db/queries_nfs.go | 124 ++ internal/db/queries_users.go | 263 +++ internal/engine/apply.go | 73 + internal/modules/module.go | 14 + internal/modules/nfs/exports.tmpl | 4 + internal/modules/nfs/nfs.go | 168 ++ internal/modules/samba/samba.go | 158 ++ internal/modules/samba/smb.conf.tmpl | 24 + internal/modules/users/users.go | 185 ++ internal/system/exec.go | 138 ++ internal/web/auth.go | 195 ++ internal/web/embed.go | 6 + internal/web/handlers_apply.go | 35 + internal/web/handlers_auth.go | 49 + internal/web/handlers_nfs.go | 148 ++ internal/web/handlers_samba.go | 169 ++ internal/web/handlers_system.go | 75 + internal/web/handlers_users.go | 156 ++ internal/web/router.go | 105 ++ scripts/install.sh | 10 + systemd/nasctl.service | 12 + web/index.html | 12 + web/package-lock.json | 2538 ++++++++++++++++++++++++++ web/package.json | 26 + web/postcss.config.js | 6 + web/src/App.tsx | 62 + web/src/DirtyContext.tsx | 60 + web/src/api.ts | 121 ++ web/src/components/DirtyBanner.tsx | 29 + web/src/components/Layout.tsx | 59 + web/src/components/Modal.tsx | 28 + web/src/index.css | 33 + web/src/main.tsx | 13 + web/src/pages/Dashboard.tsx | 85 + web/src/pages/Log.tsx | 51 + web/src/pages/Login.tsx | 52 + web/src/pages/Nfs.tsx | 151 ++ web/src/pages/Samba.tsx | 181 ++ web/src/pages/Users.tsx | 185 ++ web/tailwind.config.js | 17 + web/tsconfig.json | 21 + web/tsconfig.node.json | 13 + web/tsconfig.node.tsbuildinfo | 1 + web/tsconfig.tsbuildinfo | 1 + web/vite.config.ts | 16 + 56 files changed, 6725 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 cmd/nasctl/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/db/db.go create mode 100644 internal/db/migrations/0001_init.sql create mode 100644 internal/db/models.go create mode 100644 internal/db/queries.go create mode 100644 internal/db/queries_nfs.go create mode 100644 internal/db/queries_users.go create mode 100644 internal/engine/apply.go create mode 100644 internal/modules/module.go create mode 100644 internal/modules/nfs/exports.tmpl create mode 100644 internal/modules/nfs/nfs.go create mode 100644 internal/modules/samba/samba.go create mode 100644 internal/modules/samba/smb.conf.tmpl create mode 100644 internal/modules/users/users.go create mode 100644 internal/system/exec.go create mode 100644 internal/web/auth.go create mode 100644 internal/web/embed.go create mode 100644 internal/web/handlers_apply.go create mode 100644 internal/web/handlers_auth.go create mode 100644 internal/web/handlers_nfs.go create mode 100644 internal/web/handlers_samba.go create mode 100644 internal/web/handlers_system.go create mode 100644 internal/web/handlers_users.go create mode 100644 internal/web/router.go create mode 100644 scripts/install.sh create mode 100644 systemd/nasctl.service create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/src/App.tsx create mode 100644 web/src/DirtyContext.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/components/DirtyBanner.tsx create mode 100644 web/src/components/Layout.tsx create mode 100644 web/src/components/Modal.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/Dashboard.tsx create mode 100644 web/src/pages/Log.tsx create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/pages/Nfs.tsx create mode 100644 web/src/pages/Samba.tsx create mode 100644 web/src/pages/Users.tsx create mode 100644 web/tailwind.config.js create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/tsconfig.node.tsbuildinfo create mode 100644 web/tsconfig.tsbuildinfo create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 36b13f1..5f450d8 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,14 @@ cython_debug/ # PyPI configuration file .pypirc +# Go / nasctl +bin/ +.data/ +*.db +dist/deb/ +dist/*.deb + +# Frontend +web/node_modules/ +web/.tsbuild/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3521fdb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# nasctl — Agent Guidance + +## Build + +```bash +make build # frontend (Vite builds to internal/web/dist) + Go binary → bin/nasctl +make build-go # Go only (requires internal/web/dist to exist) +make run # dev run: uses .data/, NASCTL_EXEC_SYSTEM=false +``` + +The Vite config (`web/vite.config.ts`) outputs directly to `internal/web/dist` for Go embedding. Do not change `outDir`. + +## Testing + +```bash +make test # go test ./... +``` + +No separate frontend test command; no lint/typecheck Makefile targets. + +## Project Structure + +- `cmd/nasctl/main.go` — entrypoint, CLI flags +- `internal/web/` — HTTP server, handlers, embed.go (frontend assets) +- `internal/db/` — SQLite models and queries +- `internal/modules/samba/`, `nfs/`, `users/` — config generators and system executors +- `internal/engine/apply.go` — dirty→apply orchestration +- `web/` — React frontend source (Vite + Tailwind + TypeScript) + +## Key Runtime Behaviors + +- Changes (shares, exports, users) are staged in SQLite and marked **dirty**; they require `POST /api/apply` to take effect. +- `NASCTL_EXEC_SYSTEM=false` (default in dev via `make run`) prevents real system changes: no service reloads, no useradd, no smbpasswd. +- In production, set `NASCTL_EXEC_SYSTEM=true` and ensure `NASCTL_ALLOWED_ROOTS` restricts share/export paths. + +## Environment Variables + +| Variable | Dev default | Production default | +|---|---|---| +| `NASCTL_ADDR` | `:8080` | `:8080` | +| `NASCTL_DB` | `./.data/nasctl.db` | `/var/lib/nasctl/nasctl.db` | +| `NASCTL_SMB_CONF` | `./.data/smb.conf` | `/etc/samba/smb.conf` | +| `NASCTL_EXPORTS` | `./.data/exports` | `/etc/exports` | +| `NASCTL_EXEC_SYSTEM` | `false` | `true` | +| `NASCTL_ALLOWED_ROOTS` | (empty) | (empty = any absolute path) | + +## Frontend Dev + +```bash +cd web && npm run dev # Vite on :5173, proxies /api to :8080 +``` + +Requires `web/node_modules` (run `cd web && npm install` first). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41b81f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +BINARY=nasctl +VERSION?=0.1.0 +GO?=go +LDFLAGS=-s -w +BUILD_FLAGS=CGO_ENABLED=0 + +.PHONY: build build-all frontend run package clean tidy test + +frontend: + cd web && npm install && npm run build + +build: frontend + $(BUILD_FLAGS) $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/nasctl + +# build-go builds the binary without rebuilding the frontend (dist must exist). +build-go: + $(BUILD_FLAGS) $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/nasctl + +build-all: frontend + $(BUILD_FLAGS) GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/nasctl + $(BUILD_FLAGS) GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-arm64 ./cmd/nasctl + +run: build + NASCTL_DB=$${NASCTL_DB:-./.data/nasctl.db} \ + NASCTL_SMB_CONF=$${NASCTL_SMB_CONF:-./.data/smb.conf} \ + NASCTL_EXPORTS=$${NASCTL_EXPORTS:-./.data/exports} \ + NASCTL_EXEC_SYSTEM=false \ + ./bin/$(BINARY) --addr :8080 + +package: build-all + @mkdir -p dist/deb/usr/local/bin dist/deb/etc/systemd/system dist/deb/DEBIAN + cp bin/$(BINARY)-linux-amd64 dist/deb/usr/local/bin/$(BINARY) + cp systemd/nasctl.service dist/deb/etc/systemd/system/$(BINARY).service + printf 'Package: nasctl\nVersion: $(VERSION)\nSection: utils\nPriority: optional\nArchitecture: amd64\nMaintainer: nasctl\nDescription: Monolithic NAS control plane\n' > dist/deb/DEBIAN/control + printf '#!/bin/sh\nset -e\ninstall -d /var/lib/nasctl\nsystemctl daemon-reload\nsystemctl enable nasctl.service || true\n' > dist/deb/DEBIAN/postinst + chmod 0755 dist/deb/DEBIAN/postinst + dpkg-deb --build dist/deb dist/nasctl_$(VERSION)_amd64.deb + +clean: + rm -rf bin dist .data web/node_modules web/.tsbuild + +tidy: + $(GO) mod tidy + +test: + $(GO) test ./... diff --git a/README.md b/README.md index d57ee3a..e7bc9bb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,97 @@ -# baby-nas +# nasctl +Sistema NAS monolítico en Go, gestionable vía interfaz web, distribuido como un único binario estático. + +## Requisitos + +- Go 1.22+ +- Linux (para aplicar configuración Samba/NFS en producción) + +## Requisitos de build + +- Go 1.22+ +- Node.js 18+ y npm (solo para compilar el frontend embebido) + +## Build + +```bash +make build # compila frontend (npm) + binario Go con assets embebidos +``` + +Binario generado en `bin/nasctl`. Si ya tienes `internal/web/dist` compilado, `make build-go` compila solo el binario. + +## Ejecución local + +```bash +make run +``` + +Levanta en `http://localhost:8080`. Credenciales iniciales por defecto `admin` / `admin` (cámbialas con las variables de entorno o tras el primer login). + +Para desarrollo del frontend con hot-reload: + +```bash +cd web && npm run dev # Vite en :5173, proxya /api a :8080 +``` + +Variables de entorno útiles: + +| Variable | Default | Descripción | +|----------|---------|-------------| +| `NASCTL_ADDR` | `:8080` | Dirección HTTP | +| `NASCTL_DB` | `/var/lib/nasctl/nasctl.db` | Ruta SQLite | +| `NASCTL_SMB_CONF` | `/etc/samba/smb.conf` | Destino de `smb.conf` generado | +| `NASCTL_EXPORTS` | `/etc/exports` | Destino de `/etc/exports` generado | +| `NASCTL_EXEC_SYSTEM` | `true` | Ejecutar comandos reales del sistema (reload servicios, `useradd`, `smbpasswd`...) | +| `NASCTL_ALLOWED_ROOTS` | (vacío) | Directorios raíz permitidos para paths de shares/exports, separados por coma | +| `NASCTL_ADMIN_USER` | `admin` | Usuario admin inicial (solo si no existe ninguno) | +| `NASCTL_ADMIN_PASSWORD` | `admin` | Contraseña admin inicial (solo si no existe ninguno) | + +En desarrollo, `make run` usa `./.data/` y `NASCTL_EXEC_SYSTEM=false` (no toca el sistema real). + +## API + +Autenticación (cookie de sesión firmada, httpOnly): + +- `POST /api/auth/login` — `{username, password}` → cookie de sesión +- `POST /api/auth/logout` +- `GET /api/auth/status` + +El resto de endpoints requieren sesión válida: + +- `GET /api/dirty` — módulos con cambios pendientes +- `POST /api/apply` — aplica configuración pendiente +- `GET /api/apply/log` — historial de aplicaciones +- `GET /api/system/status` — uso de disco y estado de servicios +- `GET/POST/PUT/DELETE /api/samba/shares` — CRUD de shares Samba +- `GET/POST/PUT/DELETE /api/nfs/exports` — CRUD de exports NFS +- `GET/POST/PUT/DELETE /api/users` — CRUD de usuarios del sistema + +Todos los cambios marcan el módulo como dirty y NO se aplican al sistema hasta `POST /api/apply`. + +## Flujo dirty → apply + +1. Los cambios se guardan solo en SQLite. +2. Se marca el módulo correspondiente como dirty. +3. El usuario pulsa "Aplicar cambios" (`POST /api/apply`). +4. El motor regenera archivos de configuración desde templates y recarga servicios. + +## Empaquetado + +```bash +make package +``` + +Genera `dist/nasctl_0.1.0_amd64.deb`. + +## Estado + +Implementado: módulos Samba, NFS y usuarios (con flujo dirty→apply), autenticación web +(bcrypt + cookie de sesión firmada), validación de paths permitidos, dashboard con estado del +sistema y frontend React + Vite + Tailwind embebido en el binario. + +## Próximas mejoras posibles + +- Gestión de admins web desde la UI (cambio de contraseña) +- Polkit para delegar acciones puntuales sin correr como root +- Métricas e histórico más detallados en el dashboard diff --git a/cmd/nasctl/main.go b/cmd/nasctl/main.go new file mode 100644 index 0000000..19b244f --- /dev/null +++ b/cmd/nasctl/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "flag" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/engine" + "github.com/darroyo/nasctl/internal/modules/nfs" + "github.com/darroyo/nasctl/internal/modules/samba" + "github.com/darroyo/nasctl/internal/modules/users" + "github.com/darroyo/nasctl/internal/web" +) + +func main() { + addr := flag.String("addr", envOrDefault("NASCTL_ADDR", ":8080"), "HTTP listen address") + dbPath := flag.String("db", envOrDefault("NASCTL_DB", "/var/lib/nasctl/nasctl.db"), "SQLite database path") + smbConfPath := flag.String("smb-conf", envOrDefault("NASCTL_SMB_CONF", "/etc/samba/smb.conf"), "Samba config output path") + exportsPath := flag.String("exports", envOrDefault("NASCTL_EXPORTS", "/etc/exports"), "NFS exports output path") + execSystem := flag.Bool("exec-system", envOrDefault("NASCTL_EXEC_SYSTEM", "true") == "true", "Execute real system commands (reload services, useradd, etc.)") + allowedRoots := flag.String("allowed-roots", envOrDefault("NASCTL_ALLOWED_ROOTS", ""), "Comma-separated allowed root dirs for shares/exports (empty = any absolute path)") + adminUser := flag.String("admin-user", envOrDefault("NASCTL_ADMIN_USER", "admin"), "Initial admin username (only used if no admin exists)") + adminPass := flag.String("admin-pass", envOrDefault("NASCTL_ADMIN_PASSWORD", "admin"), "Initial admin password (only used if no admin exists)") + flag.Parse() + + if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil { + log.Fatalf("create db dir: %v", err) + } + + database, err := db.Open(*dbPath) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer database.Close() + + if err := database.Migrate(); err != nil { + log.Fatalf("migrate db: %v", err) + } + + auth, err := web.NewAuthService(database) + if err != nil { + log.Fatalf("init auth: %v", err) + } + created, err := auth.EnsureAdmin(*adminUser, *adminPass) + if err != nil { + log.Fatalf("ensure admin: %v", err) + } + if created { + log.Printf("created initial admin user %q — change the password after first login", *adminUser) + } + + sambaModule := samba.New(samba.Config{ + SMBConfPath: *smbConfPath, + Reload: *execSystem, + }) + nfsModule := nfs.New(nfs.Config{ + ExportsPath: *exportsPath, + Reload: *execSystem, + }) + usersModule := users.New(users.Config{ + Execute: *execSystem, + CreateHome: true, + }) + + eng := engine.New(sambaModule, nfsModule, usersModule) + srv := web.NewServer(database, eng, web.Options{ + AllowedRoots: parseRoots(*allowedRoots), + Auth: auth, + }) + + log.Printf("nasctl listening on %s (db=%s exec-system=%v)", *addr, *dbPath, *execSystem) + if err := http.ListenAndServe(*addr, srv.Handler()); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func parseRoots(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + roots := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + roots = append(roots, p) + } + } + return roots +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2b12807 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/darroyo/nasctl + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + golang.org/x/crypto v0.53.0 + modernc.org/sqlite v1.35.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect + golang.org/x/sys v0.46.0 // indirect + modernc.org/libc v1.61.13 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.8.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f0e67d6 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +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/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +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/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= +modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= +modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= +modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +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.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= +modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= +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.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw= +modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic= +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/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..6fb5701 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,62 @@ +package db + +import ( + "database/sql" + "embed" + "fmt" + "io/fs" + "sort" + "strings" + + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type DB struct { + conn *sql.DB +} + +func Open(path string) (*DB, error) { + conn, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + if _, err := conn.Exec(`PRAGMA foreign_keys = ON`); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + return &DB{conn: conn}, nil +} + +func (d *DB) Close() error { + return d.conn.Close() +} + +func (d *DB) Conn() *sql.DB { + return d.conn +} + +func (d *DB) Migrate() error { + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + content, err := migrationsFS.ReadFile("migrations/" + entry.Name()) + if err != nil { + return fmt.Errorf("read migration %s: %w", entry.Name(), err) + } + if _, err := d.conn.Exec(string(content)); err != nil { + return fmt.Errorf("apply migration %s: %w", entry.Name(), err) + } + } + return nil +} diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..b325f97 --- /dev/null +++ b/internal/db/migrations/0001_init.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS system_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + uid INTEGER, + gid INTEGER, + groups TEXT NOT NULL DEFAULT '[]', + smb_enabled INTEGER NOT NULL DEFAULT 0, + disabled INTEGER NOT NULL DEFAULT 0, + pending_password TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS samba_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + comment TEXT NOT NULL DEFAULT '', + read_only INTEGER NOT NULL DEFAULT 0, + guest_ok INTEGER NOT NULL DEFAULT 0, + valid_users TEXT NOT NULL DEFAULT '[]', + valid_groups TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS nfs_exports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + clients TEXT NOT NULL DEFAULT '[]', + options TEXT NOT NULL DEFAULT 'rw,sync,no_root_squash', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS deleted_system_users ( + username TEXT PRIMARY KEY, + marked_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS dirty_modules ( + module TEXT PRIMARY KEY, + marked_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS apply_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module TEXT NOT NULL, + message TEXT NOT NULL, + success INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..807ac94 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,60 @@ +package db + +import "time" + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + UID *int64 `json:"uid,omitempty"` + GID *int64 `json:"gid,omitempty"` + Groups []string `json:"groups"` + SMBEnabled bool `json:"smb_enabled"` + Disabled bool `json:"disabled"` + // PendingPassword holds a not-yet-applied password. Never serialized. + PendingPassword string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Admin struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SambaShare struct { + ID int64 `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Comment string `json:"comment"` + ReadOnly bool `json:"read_only"` + GuestOK bool `json:"guest_ok"` + ValidUsers []string `json:"valid_users"` + ValidGroups []string `json:"valid_groups"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type NFSExport struct { + ID int64 `json:"id"` + Path string `json:"path"` + Clients []string `json:"clients"` + Options string `json:"options"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DirtyModule struct { + Module string `json:"module"` + MarkedAt time.Time `json:"marked_at"` +} + +type ApplyLogEntry struct { + ID int64 `json:"id"` + Module string `json:"module"` + Message string `json:"message"` + Success bool `json:"success"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/db/queries.go b/internal/db/queries.go new file mode 100644 index 0000000..0f429b0 --- /dev/null +++ b/internal/db/queries.go @@ -0,0 +1,286 @@ +package db + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +func parseTime(value string) time.Time { + t, err := time.Parse(time.RFC3339, value) + if err != nil { + t, _ = time.Parse("2006-01-02 15:04:05", value) + } + return t +} + +func encodeJSONStrings(values []string) (string, error) { + if values == nil { + values = []string{} + } + data, err := json.Marshal(values) + if err != nil { + return "", err + } + return string(data), nil +} + +func decodeJSONStrings(raw string) ([]string, error) { + if raw == "" { + return []string{}, nil + } + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, err + } + return values, nil +} + +func scanSambaShare(row interface { + Scan(dest ...any) error +}) (SambaShare, error) { + var share SambaShare + var readOnly, guestOK int + var validUsers, validGroups, createdAt, updatedAt string + if err := row.Scan( + &share.ID, + &share.Name, + &share.Path, + &share.Comment, + &readOnly, + &guestOK, + &validUsers, + &validGroups, + &createdAt, + &updatedAt, + ); err != nil { + return SambaShare{}, err + } + share.ReadOnly = readOnly != 0 + share.GuestOK = guestOK != 0 + var err error + share.ValidUsers, err = decodeJSONStrings(validUsers) + if err != nil { + return SambaShare{}, err + } + share.ValidGroups, err = decodeJSONStrings(validGroups) + if err != nil { + return SambaShare{}, err + } + share.CreatedAt = parseTime(createdAt) + share.UpdatedAt = parseTime(updatedAt) + return share, nil +} + +func (d *DB) MarkDirty(module string) error { + _, err := d.conn.Exec( + `INSERT INTO dirty_modules (module, marked_at) VALUES (?, datetime('now')) + ON CONFLICT(module) DO UPDATE SET marked_at = datetime('now')`, + module, + ) + if err != nil { + return fmt.Errorf("mark dirty %q: %w", module, err) + } + return nil +} + +func (d *DB) ClearDirty(module string) error { + _, err := d.conn.Exec(`DELETE FROM dirty_modules WHERE module = ?`, module) + if err != nil { + return fmt.Errorf("clear dirty %q: %w", module, err) + } + return nil +} + +func (d *DB) IsDirty(module string) (bool, error) { + var count int + err := d.conn.QueryRow(`SELECT COUNT(1) FROM dirty_modules WHERE module = ?`, module).Scan(&count) + if err != nil { + return false, fmt.Errorf("is dirty %q: %w", module, err) + } + return count > 0, nil +} + +func (d *DB) ListDirty() ([]DirtyModule, error) { + rows, err := d.conn.Query(`SELECT module, marked_at FROM dirty_modules ORDER BY marked_at ASC`) + if err != nil { + return nil, fmt.Errorf("list dirty: %w", err) + } + defer rows.Close() + + var modules []DirtyModule + for rows.Next() { + var module DirtyModule + var markedAt string + if err := rows.Scan(&module.Module, &markedAt); err != nil { + return nil, fmt.Errorf("scan dirty: %w", err) + } + module.MarkedAt = parseTime(markedAt) + modules = append(modules, module) + } + if err := rows.Err(); err != nil { + return nil, err + } + return modules, nil +} + +func (d *DB) AppendApplyLog(module, message string, success bool) error { + successInt := 0 + if success { + successInt = 1 + } + _, err := d.conn.Exec( + `INSERT INTO apply_log (module, message, success, created_at) VALUES (?, ?, ?, datetime('now'))`, + module, message, successInt, + ) + if err != nil { + return fmt.Errorf("append apply log: %w", err) + } + return nil +} + +func (d *DB) ListApplyLog(limit int) ([]ApplyLogEntry, error) { + if limit <= 0 { + limit = 100 + } + rows, err := d.conn.Query( + `SELECT id, module, message, success, created_at FROM apply_log ORDER BY id DESC LIMIT ?`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("list apply log: %w", err) + } + defer rows.Close() + + var entries []ApplyLogEntry + for rows.Next() { + var entry ApplyLogEntry + var success int + var createdAt string + if err := rows.Scan(&entry.ID, &entry.Module, &entry.Message, &success, &createdAt); err != nil { + return nil, fmt.Errorf("scan apply log: %w", err) + } + entry.Success = success != 0 + entry.CreatedAt = parseTime(createdAt) + entries = append(entries, entry) + } + return entries, rows.Err() +} + +func (d *DB) ListSambaShares() ([]SambaShare, error) { + rows, err := d.conn.Query(` + SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at + FROM samba_shares ORDER BY name ASC`) + if err != nil { + return nil, fmt.Errorf("list samba shares: %w", err) + } + defer rows.Close() + + var shares []SambaShare + for rows.Next() { + share, err := scanSambaShare(rows) + if err != nil { + return nil, fmt.Errorf("scan samba share: %w", err) + } + shares = append(shares, share) + } + return shares, rows.Err() +} + +func (d *DB) GetSambaShare(id int64) (SambaShare, error) { + row := d.conn.QueryRow(` + SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at + FROM samba_shares WHERE id = ?`, id) + share, err := scanSambaShare(row) + if err == sql.ErrNoRows { + return SambaShare{}, fmt.Errorf("samba share not found") + } + if err != nil { + return SambaShare{}, fmt.Errorf("get samba share: %w", err) + } + return share, nil +} + +func (d *DB) CreateSambaShare(share SambaShare) (SambaShare, error) { + validUsers, err := encodeJSONStrings(share.ValidUsers) + if err != nil { + return SambaShare{}, err + } + validGroups, err := encodeJSONStrings(share.ValidGroups) + if err != nil { + return SambaShare{}, err + } + readOnly := 0 + if share.ReadOnly { + readOnly = 1 + } + guestOK := 0 + if share.GuestOK { + guestOK = 1 + } + result, err := d.conn.Exec(` + INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, + ) + if err != nil { + return SambaShare{}, fmt.Errorf("create samba share: %w", err) + } + id, err := result.LastInsertId() + if err != nil { + return SambaShare{}, fmt.Errorf("last insert id: %w", err) + } + return d.GetSambaShare(id) +} + +func (d *DB) UpdateSambaShare(id int64, share SambaShare) (SambaShare, error) { + validUsers, err := encodeJSONStrings(share.ValidUsers) + if err != nil { + return SambaShare{}, err + } + validGroups, err := encodeJSONStrings(share.ValidGroups) + if err != nil { + return SambaShare{}, err + } + readOnly := 0 + if share.ReadOnly { + readOnly = 1 + } + guestOK := 0 + if share.GuestOK { + guestOK = 1 + } + result, err := d.conn.Exec(` + UPDATE samba_shares + SET name = ?, path = ?, comment = ?, read_only = ?, guest_ok = ?, valid_users = ?, valid_groups = ?, updated_at = datetime('now') + WHERE id = ?`, + share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, id, + ) + if err != nil { + return SambaShare{}, fmt.Errorf("update samba share: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return SambaShare{}, err + } + if rows == 0 { + return SambaShare{}, fmt.Errorf("samba share not found") + } + return d.GetSambaShare(id) +} + +func (d *DB) DeleteSambaShare(id int64) error { + result, err := d.conn.Exec(`DELETE FROM samba_shares WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete samba share: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("samba share not found") + } + return nil +} diff --git a/internal/db/queries_nfs.go b/internal/db/queries_nfs.go new file mode 100644 index 0000000..622e76d --- /dev/null +++ b/internal/db/queries_nfs.go @@ -0,0 +1,124 @@ +package db + +import ( + "database/sql" + "fmt" +) + +func scanNFSExport(row interface { + Scan(dest ...any) error +}) (NFSExport, error) { + var export NFSExport + var clients, createdAt, updatedAt string + if err := row.Scan( + &export.ID, + &export.Path, + &clients, + &export.Options, + &createdAt, + &updatedAt, + ); err != nil { + return NFSExport{}, err + } + var err error + export.Clients, err = decodeJSONStrings(clients) + if err != nil { + return NFSExport{}, err + } + export.CreatedAt = parseTime(createdAt) + export.UpdatedAt = parseTime(updatedAt) + return export, nil +} + +func (d *DB) ListNFSExports() ([]NFSExport, error) { + rows, err := d.conn.Query(` + SELECT id, path, clients, options, created_at, updated_at + FROM nfs_exports ORDER BY path ASC`) + if err != nil { + return nil, fmt.Errorf("list nfs exports: %w", err) + } + defer rows.Close() + + var exports []NFSExport + for rows.Next() { + export, err := scanNFSExport(rows) + if err != nil { + return nil, fmt.Errorf("scan nfs export: %w", err) + } + exports = append(exports, export) + } + return exports, rows.Err() +} + +func (d *DB) GetNFSExport(id int64) (NFSExport, error) { + row := d.conn.QueryRow(` + SELECT id, path, clients, options, created_at, updated_at + FROM nfs_exports WHERE id = ?`, id) + export, err := scanNFSExport(row) + if err == sql.ErrNoRows { + return NFSExport{}, fmt.Errorf("nfs export not found") + } + if err != nil { + return NFSExport{}, fmt.Errorf("get nfs export: %w", err) + } + return export, nil +} + +func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) { + clients, err := encodeJSONStrings(export.Clients) + if err != nil { + return NFSExport{}, err + } + result, err := d.conn.Exec(` + INSERT INTO nfs_exports (path, clients, options) + VALUES (?, ?, ?)`, + export.Path, clients, export.Options, + ) + if err != nil { + return NFSExport{}, fmt.Errorf("create nfs export: %w", err) + } + id, err := result.LastInsertId() + if err != nil { + return NFSExport{}, fmt.Errorf("last insert id: %w", err) + } + return d.GetNFSExport(id) +} + +func (d *DB) UpdateNFSExport(id int64, export NFSExport) (NFSExport, error) { + clients, err := encodeJSONStrings(export.Clients) + if err != nil { + return NFSExport{}, err + } + result, err := d.conn.Exec(` + UPDATE nfs_exports + SET path = ?, clients = ?, options = ?, updated_at = datetime('now') + WHERE id = ?`, + export.Path, clients, export.Options, id, + ) + if err != nil { + return NFSExport{}, fmt.Errorf("update nfs export: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return NFSExport{}, err + } + if rows == 0 { + return NFSExport{}, fmt.Errorf("nfs export not found") + } + return d.GetNFSExport(id) +} + +func (d *DB) DeleteNFSExport(id int64) error { + result, err := d.conn.Exec(`DELETE FROM nfs_exports WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete nfs export: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("nfs export not found") + } + return nil +} diff --git a/internal/db/queries_users.go b/internal/db/queries_users.go new file mode 100644 index 0000000..252b438 --- /dev/null +++ b/internal/db/queries_users.go @@ -0,0 +1,263 @@ +package db + +import ( + "database/sql" + "fmt" +) + +func scanUser(row interface { + Scan(dest ...any) error +}) (User, error) { + var user User + var uid, gid sql.NullInt64 + var groups, createdAt, updatedAt string + var smbEnabled, disabled int + if err := row.Scan( + &user.ID, + &user.Username, + &uid, + &gid, + &groups, + &smbEnabled, + &disabled, + &user.PendingPassword, + &createdAt, + &updatedAt, + ); err != nil { + return User{}, err + } + if uid.Valid { + user.UID = &uid.Int64 + } + if gid.Valid { + user.GID = &gid.Int64 + } + user.SMBEnabled = smbEnabled != 0 + user.Disabled = disabled != 0 + var err error + user.Groups, err = decodeJSONStrings(groups) + if err != nil { + return User{}, err + } + user.CreatedAt = parseTime(createdAt) + user.UpdatedAt = parseTime(updatedAt) + return user, nil +} + +const userColumns = `id, username, uid, gid, groups, smb_enabled, disabled, pending_password, created_at, updated_at` + +func (d *DB) ListUsers() ([]User, error) { + rows, err := d.conn.Query(`SELECT ` + userColumns + ` FROM system_users ORDER BY username ASC`) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + users = append(users, user) + } + return users, rows.Err() +} + +func (d *DB) GetUser(id int64) (User, error) { + row := d.conn.QueryRow(`SELECT `+userColumns+` FROM system_users WHERE id = ?`, id) + user, err := scanUser(row) + if err == sql.ErrNoRows { + return User{}, fmt.Errorf("user not found") + } + if err != nil { + return User{}, fmt.Errorf("get user: %w", err) + } + return user, nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func (d *DB) CreateUser(user User) (User, error) { + groups, err := encodeJSONStrings(user.Groups) + if err != nil { + return User{}, err + } + result, err := d.conn.Exec(` + INSERT INTO system_users (username, groups, smb_enabled, disabled, pending_password) + VALUES (?, ?, ?, ?, ?)`, + user.Username, groups, boolToInt(user.SMBEnabled), boolToInt(user.Disabled), user.PendingPassword, + ) + if err != nil { + return User{}, fmt.Errorf("create user: %w", err) + } + id, err := result.LastInsertId() + if err != nil { + return User{}, fmt.Errorf("last insert id: %w", err) + } + return d.GetUser(id) +} + +func (d *DB) UpdateUser(id int64, user User) (User, error) { + groups, err := encodeJSONStrings(user.Groups) + if err != nil { + return User{}, err + } + result, err := d.conn.Exec(` + UPDATE system_users + SET groups = ?, smb_enabled = ?, disabled = ?, pending_password = ?, updated_at = datetime('now') + WHERE id = ?`, + groups, boolToInt(user.SMBEnabled), boolToInt(user.Disabled), user.PendingPassword, id, + ) + if err != nil { + return User{}, fmt.Errorf("update user: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return User{}, err + } + if rows == 0 { + return User{}, fmt.Errorf("user not found") + } + return d.GetUser(id) +} + +func (d *DB) SetUserPendingPassword(id int64, password string) error { + _, err := d.conn.Exec( + `UPDATE system_users SET pending_password = ?, updated_at = datetime('now') WHERE id = ?`, + password, id, + ) + if err != nil { + return fmt.Errorf("set pending password: %w", err) + } + return nil +} + +// ClearUserPendingPassword removes the stored password once it has been applied. +func (d *DB) ClearUserPendingPassword(id int64) error { + _, err := d.conn.Exec( + `UPDATE system_users SET pending_password = '' WHERE id = ?`, id, + ) + if err != nil { + return fmt.Errorf("clear pending password: %w", err) + } + return nil +} + +// DeleteUser removes the user row and records a tombstone so the users module +// can run userdel on the next apply. +func (d *DB) DeleteUser(id int64) error { + user, err := d.GetUser(id) + if err != nil { + return err + } + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM system_users WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete user: %w", err) + } + if _, err := tx.Exec( + `INSERT INTO deleted_system_users (username, marked_at) VALUES (?, datetime('now')) + ON CONFLICT(username) DO UPDATE SET marked_at = datetime('now')`, + user.Username, + ); err != nil { + return fmt.Errorf("record deleted user: %w", err) + } + return tx.Commit() +} + +func (d *DB) ListDeletedUsers() ([]string, error) { + rows, err := d.conn.Query(`SELECT username FROM deleted_system_users ORDER BY username ASC`) + if err != nil { + return nil, fmt.Errorf("list deleted users: %w", err) + } + defer rows.Close() + + var usernames []string + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, err + } + usernames = append(usernames, username) + } + return usernames, rows.Err() +} + +func (d *DB) ClearDeletedUser(username string) error { + _, err := d.conn.Exec(`DELETE FROM deleted_system_users WHERE username = ?`, username) + if err != nil { + return fmt.Errorf("clear deleted user: %w", err) + } + return nil +} + +func (d *DB) GetAdminByUsername(username string) (Admin, error) { + row := d.conn.QueryRow( + `SELECT id, username, password_hash, created_at, updated_at FROM admins WHERE username = ?`, + username, + ) + var admin Admin + var createdAt, updatedAt string + err := row.Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return Admin{}, fmt.Errorf("admin not found") + } + if err != nil { + return Admin{}, fmt.Errorf("get admin: %w", err) + } + admin.CreatedAt = parseTime(createdAt) + admin.UpdatedAt = parseTime(updatedAt) + return admin, nil +} + +func (d *DB) CountAdmins() (int, error) { + var count int + if err := d.conn.QueryRow(`SELECT COUNT(1) FROM admins`).Scan(&count); err != nil { + return 0, fmt.Errorf("count admins: %w", err) + } + return count, nil +} + +func (d *DB) CreateAdmin(username, passwordHash string) (Admin, error) { + if _, err := d.conn.Exec( + `INSERT INTO admins (username, password_hash) VALUES (?, ?)`, + username, passwordHash, + ); err != nil { + return Admin{}, fmt.Errorf("create admin: %w", err) + } + return d.GetAdminByUsername(username) +} + +func (d *DB) GetSetting(key string) (string, bool, error) { + var value string + err := d.conn.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("get setting %q: %w", key, err) + } + return value, true, nil +} + +func (d *DB) SetSetting(key, value string) error { + _, err := d.conn.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + key, value, + ) + if err != nil { + return fmt.Errorf("set setting %q: %w", key, err) + } + return nil +} diff --git a/internal/engine/apply.go b/internal/engine/apply.go new file mode 100644 index 0000000..f466ed8 --- /dev/null +++ b/internal/engine/apply.go @@ -0,0 +1,73 @@ +package engine + +import ( + "context" + "fmt" + "strings" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/modules" +) + +type ModuleResult struct { + Module string `json:"module"` + Applied bool `json:"applied"` + Error string `json:"error,omitempty"` +} + +type ApplyResult struct { + Results []ModuleResult `json:"results"` +} + +type Engine struct { + modules []modules.Module +} + +func New(mods ...modules.Module) *Engine { + return &Engine{modules: mods} +} + +func (e *Engine) DirtyList(ctx context.Context, database *db.DB) ([]db.DirtyModule, error) { + return database.ListDirty() +} + +func (e *Engine) ApplyAll(ctx context.Context, database *db.DB) (ApplyResult, error) { + result := ApplyResult{Results: make([]ModuleResult, 0)} + var errors []string + + for _, mod := range e.modules { + dirty, err := mod.IsDirty(ctx, database) + if err != nil { + errors = append(errors, fmt.Sprintf("%s: check dirty: %v", mod.Name(), err)) + result.Results = append(result.Results, ModuleResult{ + Module: mod.Name(), + Error: err.Error(), + }) + continue + } + if !dirty { + continue + } + + if err := mod.Apply(ctx, database); err != nil { + msg := err.Error() + errors = append(errors, fmt.Sprintf("%s: %s", mod.Name(), msg)) + _ = database.AppendApplyLog(mod.Name(), msg, false) + result.Results = append(result.Results, ModuleResult{ + Module: mod.Name(), + Error: msg, + }) + continue + } + + result.Results = append(result.Results, ModuleResult{ + Module: mod.Name(), + Applied: true, + }) + } + + if len(errors) > 0 { + return result, fmt.Errorf("%s", strings.Join(errors, "; ")) + } + return result, nil +} diff --git a/internal/modules/module.go b/internal/modules/module.go new file mode 100644 index 0000000..6674126 --- /dev/null +++ b/internal/modules/module.go @@ -0,0 +1,14 @@ +package modules + +import ( + "context" + + "github.com/darroyo/nasctl/internal/db" +) + +// Module represents a configurable subsystem managed by nasctl. +type Module interface { + Name() string + IsDirty(ctx context.Context, database *db.DB) (bool, error) + Apply(ctx context.Context, database *db.DB) error +} diff --git a/internal/modules/nfs/exports.tmpl b/internal/modules/nfs/exports.tmpl new file mode 100644 index 0000000..b07b55e --- /dev/null +++ b/internal/modules/nfs/exports.tmpl @@ -0,0 +1,4 @@ +# Generated by nasctl. Do not edit manually. +{{range .Exports}} +{{.Path}} {{.ClientSpec}} +{{- end}} diff --git a/internal/modules/nfs/nfs.go b/internal/modules/nfs/nfs.go new file mode 100644 index 0000000..f67dd2a --- /dev/null +++ b/internal/modules/nfs/nfs.go @@ -0,0 +1,168 @@ +package nfs + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/system" +) + +const ModuleName = "nfs" + +//go:embed exports.tmpl +var exportsTemplate embed.FS + +type Config struct { + ExportsPath string + Reload bool +} + +type Module struct { + cfg Config +} + +type templateExport struct { + Path string + ClientSpec string +} + +type templateData struct { + Exports []templateExport +} + +func New(cfg Config) *Module { + if cfg.ExportsPath == "" { + cfg.ExportsPath = "/etc/exports" + } + return &Module{cfg: cfg} +} + +func (m *Module) Name() string { + return ModuleName +} + +func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { + return database.IsDirty(ModuleName) +} + +func (m *Module) Apply(ctx context.Context, database *db.DB) error { + exports, err := database.ListNFSExports() + if err != nil { + return err + } + + content, err := m.renderConfig(exports) + if err != nil { + return err + } + + if err := writeAtomic(m.cfg.ExportsPath, content); err != nil { + return err + } + + if m.cfg.Reload { + stdout, stderr, err := system.Run(ctx, "exportfs", "-ra") + if err != nil { + return fmt.Errorf("exportfs -ra: %w (stdout=%q stderr=%q)", err, stdout, stderr) + } + } + + message := fmt.Sprintf("applied %d nfs export(s) to %s", len(exports), m.cfg.ExportsPath) + if err := database.AppendApplyLog(ModuleName, message, true); err != nil { + return err + } + return database.ClearDirty(ModuleName) +} + +// clientSpec builds the "client(opts) client(opts)" segment of an exports line. +// If no clients are configured, it defaults to "*(opts)". +func clientSpec(clients []string, options string) string { + opts := strings.TrimSpace(options) + if opts == "" { + opts = "ro" + } + if len(clients) == 0 { + return fmt.Sprintf("*(%s)", opts) + } + specs := make([]string, 0, len(clients)) + for _, client := range clients { + client = strings.TrimSpace(client) + if client == "" { + continue + } + specs = append(specs, fmt.Sprintf("%s(%s)", client, opts)) + } + if len(specs) == 0 { + return fmt.Sprintf("*(%s)", opts) + } + return strings.Join(specs, " ") +} + +func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) { + tmplContent, err := exportsTemplate.ReadFile("exports.tmpl") + if err != nil { + return nil, fmt.Errorf("read exports template: %w", err) + } + + tmpl, err := template.New("exports").Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parse exports template: %w", err) + } + + data := templateData{Exports: make([]templateExport, 0, len(exports))} + for _, export := range exports { + data.Exports = append(data.Exports, templateExport{ + Path: export.Path, + ClientSpec: clientSpec(export.Clients, export.Options), + }) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("execute exports template: %w", err) + } + return buf.Bytes(), nil +} + +func writeAtomic(path string, content []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".exports.*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + + if _, err := tmp.Write(content); err != nil { + cleanup() + return fmt.Errorf("write temp config: %w", err) + } + if err := tmp.Chmod(0o644); err != nil { + cleanup() + return fmt.Errorf("chmod temp config: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("close temp config: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + cleanup() + return fmt.Errorf("rename temp config: %w", err) + } + return nil +} diff --git a/internal/modules/samba/samba.go b/internal/modules/samba/samba.go new file mode 100644 index 0000000..4436301 --- /dev/null +++ b/internal/modules/samba/samba.go @@ -0,0 +1,158 @@ +package samba + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/system" +) + +const ModuleName = "samba" + +//go:embed smb.conf.tmpl +var smbConfTemplate embed.FS + +type Config struct { + SMBConfPath string + Reload bool +} + +type Module struct { + cfg Config +} + +type templateShare struct { + Name string + Path string + Comment string + ReadOnly bool + GuestOK bool + ValidUsers []string + ValidGroups []string +} + +type templateData struct { + Shares []templateShare +} + +func New(cfg Config) *Module { + if cfg.SMBConfPath == "" { + cfg.SMBConfPath = "/etc/samba/smb.conf" + } + return &Module{cfg: cfg} +} + +func (m *Module) Name() string { + return ModuleName +} + +func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { + return database.IsDirty(ModuleName) +} + +func (m *Module) Apply(ctx context.Context, database *db.DB) error { + shares, err := database.ListSambaShares() + if err != nil { + return err + } + + content, err := m.renderConfig(shares) + if err != nil { + return err + } + + if err := writeAtomic(m.cfg.SMBConfPath, content); err != nil { + return err + } + + if m.cfg.Reload { + stdout, stderr, err := system.Run(ctx, "systemctl", "reload", "smbd") + if err != nil { + return fmt.Errorf("reload smbd: %w (stdout=%q stderr=%q)", err, stdout, stderr) + } + } + + message := fmt.Sprintf("applied %d samba share(s) to %s", len(shares), m.cfg.SMBConfPath) + if err := database.AppendApplyLog(ModuleName, message, true); err != nil { + return err + } + return database.ClearDirty(ModuleName) +} + +func (m *Module) renderConfig(shares []db.SambaShare) ([]byte, error) { + tmplContent, err := smbConfTemplate.ReadFile("smb.conf.tmpl") + if err != nil { + return nil, fmt.Errorf("read smb.conf template: %w", err) + } + + funcMap := template.FuncMap{ + "join": strings.Join, + } + + tmpl, err := template.New("smb.conf").Funcs(funcMap).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parse smb.conf template: %w", err) + } + + data := templateData{Shares: make([]templateShare, 0, len(shares))} + for _, share := range shares { + data.Shares = append(data.Shares, templateShare{ + Name: share.Name, + Path: share.Path, + Comment: share.Comment, + ReadOnly: share.ReadOnly, + GuestOK: share.GuestOK, + ValidUsers: share.ValidUsers, + ValidGroups: share.ValidGroups, + }) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("execute smb.conf template: %w", err) + } + return buf.Bytes(), nil +} + +func writeAtomic(path string, content []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".smb.conf.*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + + if _, err := tmp.Write(content); err != nil { + cleanup() + return fmt.Errorf("write temp config: %w", err) + } + if err := tmp.Chmod(0o644); err != nil { + cleanup() + return fmt.Errorf("chmod temp config: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("close temp config: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + cleanup() + return fmt.Errorf("rename temp config: %w", err) + } + return nil +} diff --git a/internal/modules/samba/smb.conf.tmpl b/internal/modules/samba/smb.conf.tmpl new file mode 100644 index 0000000..6b6dd87 --- /dev/null +++ b/internal/modules/samba/smb.conf.tmpl @@ -0,0 +1,24 @@ +# Generated by nasctl. Do not edit manually. + +[global] + workgroup = WORKGROUP + server string = nasctl + security = user + map to guest = Bad User + dns proxy = no + +{{range .Shares}} +[{{.Name}}] + path = {{.Path}} + comment = {{.Comment}} + browseable = yes + read only = {{if .ReadOnly}}yes{{else}}no{{end}} + guest ok = {{if .GuestOK}}yes{{else}}no{{end}} +{{- if .ValidUsers}} + valid users = {{join .ValidUsers ","}} +{{- end}} +{{- if .ValidGroups}} + valid groups = {{join .ValidGroups ","}} +{{- end}} + +{{end}} diff --git a/internal/modules/users/users.go b/internal/modules/users/users.go new file mode 100644 index 0000000..4242ee0 --- /dev/null +++ b/internal/modules/users/users.go @@ -0,0 +1,185 @@ +package users + +import ( + "context" + "fmt" + "strings" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/system" +) + +const ModuleName = "users" + +type Config struct { + // Execute controls whether system commands actually run. Disabled in dev. + Execute bool + // DefaultShell used when creating users. + DefaultShell string + // CreateHome creates a home directory for new users. + CreateHome bool +} + +type Module struct { + cfg Config +} + +func New(cfg Config) *Module { + if cfg.DefaultShell == "" { + cfg.DefaultShell = "/usr/sbin/nologin" + } + return &Module{cfg: cfg} +} + +func (m *Module) Name() string { + return ModuleName +} + +func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { + return database.IsDirty(ModuleName) +} + +func (m *Module) Apply(ctx context.Context, database *db.DB) error { + deleted, err := database.ListDeletedUsers() + if err != nil { + return err + } + for _, username := range deleted { + if err := m.deleteUser(ctx, username); err != nil { + return err + } + if err := database.ClearDeletedUser(username); err != nil { + return err + } + } + + usersList, err := database.ListUsers() + if err != nil { + return err + } + for _, user := range usersList { + if err := m.reconcileUser(ctx, database, user); err != nil { + return err + } + } + + message := fmt.Sprintf("reconciled %d user(s), %d deletion(s)", len(usersList), len(deleted)) + if err := database.AppendApplyLog(ModuleName, message, true); err != nil { + return err + } + return database.ClearDirty(ModuleName) +} + +func (m *Module) reconcileUser(ctx context.Context, database *db.DB, user db.User) error { + if err := system.ValidateUsername(user.Username); err != nil { + return err + } + + exists := m.cfg.Execute && system.UserExists(ctx, user.Username) + + if !m.cfg.Execute { + // In dev mode we do not touch the system; just clear pending secrets + // so they are not retained indefinitely. + if user.PendingPassword != "" { + return database.ClearUserPendingPassword(user.ID) + } + return nil + } + + groups := sanitizeGroups(user.Groups) + + if !exists { + args := []string{"-s", m.cfg.DefaultShell} + if m.cfg.CreateHome { + args = append(args, "-m") + } else { + args = append(args, "-M") + } + if len(groups) > 0 { + args = append(args, "-G", strings.Join(groups, ",")) + } + args = append(args, user.Username) + if err := m.run(ctx, "useradd", args...); err != nil { + return err + } + } else { + args := []string{"-G", strings.Join(groups, ",")} + args = append(args, user.Username) + if err := m.run(ctx, "usermod", args...); err != nil { + return err + } + } + + if user.Disabled { + if err := m.run(ctx, "usermod", "-L", user.Username); err != nil { + return err + } + } else { + // Unlock; ignore error when there is nothing to unlock. + _ = m.run(ctx, "usermod", "-U", user.Username) + } + + if err := m.applySMB(ctx, database, user); err != nil { + return err + } + + return nil +} + +func (m *Module) applySMB(ctx context.Context, database *db.DB, user db.User) error { + if user.SMBEnabled { + if user.PendingPassword != "" { + // smbpasswd -a -s reads the new password twice from stdin. + input := user.PendingPassword + "\n" + user.PendingPassword + "\n" + stdout, stderr, err := system.RunWithInput(ctx, input, "smbpasswd", "-a", "-s", user.Username) + if err != nil { + return fmt.Errorf("smbpasswd -a for %s: %w (stdout=%q stderr=%q)", user.Username, err, stdout, stderr) + } + if err := database.ClearUserPendingPassword(user.ID); err != nil { + return err + } + } + // Ensure the smb account is enabled. + _ = m.run(ctx, "smbpasswd", "-e", user.Username) + } else { + // Disable SMB access; ignore error when the account is not present. + _ = m.run(ctx, "smbpasswd", "-x", user.Username) + } + return nil +} + +func (m *Module) deleteUser(ctx context.Context, username string) error { + if err := system.ValidateUsername(username); err != nil { + return err + } + if !m.cfg.Execute { + return nil + } + // Remove SMB entry first (ignore if absent), then the system user. + _ = m.run(ctx, "smbpasswd", "-x", username) + if system.UserExists(ctx, username) { + if err := m.run(ctx, "userdel", username); err != nil { + return err + } + } + return nil +} + +func (m *Module) run(ctx context.Context, name string, args ...string) error { + stdout, stderr, err := system.Run(ctx, name, args...) + if err != nil { + return fmt.Errorf("%s %s: %w (stdout=%q stderr=%q)", name, strings.Join(args, " "), err, stdout, stderr) + } + return nil +} + +func sanitizeGroups(groups []string) []string { + out := make([]string, 0, len(groups)) + for _, g := range groups { + g = strings.TrimSpace(g) + if g != "" { + out = append(out, g) + } + } + return out +} diff --git a/internal/system/exec.go b/internal/system/exec.go new file mode 100644 index 0000000..b5ed1e7 --- /dev/null +++ b/internal/system/exec.go @@ -0,0 +1,138 @@ +package system + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var ( + usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`) + shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + nfsOptionPattern = regexp.MustCompile(`^[a-z_]+$`) + nfsClientPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:/\-\*]+$`) + allowedNFSOptions = map[string]bool{ + "rw": true, "ro": true, "sync": true, "async": true, + "root_squash": true, "no_root_squash": true, "all_squash": true, + "no_all_squash": true, "subtree_check": true, "no_subtree_check": true, + "secure": true, "insecure": true, "wdelay": true, "no_wdelay": true, + "hide": true, "nohide": true, "crossmnt": true, "fsid": true, + } +) + +// Run executes a command with explicit arguments (never via shell). +func Run(ctx context.Context, name string, args ...string) (stdout, stderr string, err error) { + cmd := exec.CommandContext(ctx, name, args...) + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err = cmd.Run() + return outBuf.String(), errBuf.String(), err +} + +// RunWithInput executes a command feeding stdin from the provided string. +// Used for secrets (e.g. passwords) so they never appear in the argument list. +func RunWithInput(ctx context.Context, stdin, name string, args ...string) (stdout, stderr string, err error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = strings.NewReader(stdin) + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err = cmd.Run() + return outBuf.String(), errBuf.String(), err +} + +// UserExists reports whether a system user is present (via `id -u`). +func UserExists(ctx context.Context, username string) bool { + _, _, err := Run(ctx, "id", "-u", username) + return err == nil +} + +// ValidateUsername checks Linux username constraints. +func ValidateUsername(username string) error { + if username == "" || len(username) > 32 { + return fmt.Errorf("invalid username length") + } + if !usernamePattern.MatchString(username) { + return fmt.Errorf("invalid username: %q", username) + } + return nil +} + +// ValidatePath ensures path is absolute and does not contain traversal. +func ValidatePath(path string) error { + if path == "" { + return fmt.Errorf("path is required") + } + if !filepath.IsAbs(path) { + return fmt.Errorf("path must be absolute: %q", path) + } + clean := filepath.Clean(path) + if strings.Contains(clean, "..") { + return fmt.Errorf("path must not contain .. segments: %q", path) + } + return nil +} + +// ValidateShareName checks Samba share name constraints. +func ValidateShareName(name string) error { + if name == "" || len(name) > 80 { + return fmt.Errorf("invalid share name length") + } + if !shareNamePattern.MatchString(name) { + return fmt.Errorf("invalid share name: %q", name) + } + return nil +} + +// ValidatePathAllowed validates a path and, if allowedRoots is non-empty, +// ensures the path is contained within one of the configured roots. +func ValidatePathAllowed(path string, allowedRoots []string) error { + if err := ValidatePath(path); err != nil { + return err + } + if len(allowedRoots) == 0 { + return nil + } + clean := filepath.Clean(path) + for _, root := range allowedRoots { + root = filepath.Clean(root) + if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) { + return nil + } + } + return fmt.Errorf("path %q is not within an allowed directory", path) +} + +// ValidateNFSClient checks an NFS client/network specifier. +func ValidateNFSClient(client string) error { + if client == "" || len(client) > 255 { + return fmt.Errorf("invalid nfs client length") + } + if !nfsClientPattern.MatchString(client) { + return fmt.Errorf("invalid nfs client: %q", client) + } + return nil +} + +// ValidateNFSOptions checks a comma-separated list of export options against +// a whitelist, rejecting anything that could break the exports file. +func ValidateNFSOptions(options string) error { + for _, opt := range strings.Split(options, ",") { + opt = strings.TrimSpace(opt) + if opt == "" { + continue + } + key := opt + if idx := strings.IndexByte(opt, '='); idx >= 0 { + key = opt[:idx] + } + if !nfsOptionPattern.MatchString(key) || !allowedNFSOptions[key] { + return fmt.Errorf("invalid nfs option: %q", opt) + } + } + return nil +} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..6dcaaca --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,195 @@ +package web + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/engine" +) + +const ( + sessionCookieName = "nasctl_session" + sessionTTL = 12 * time.Hour + signingKeySetting = "session_signing_key" +) + +type AuthService struct { + db *db.DB + secret []byte +} + +func NewAuthService(database *db.DB) (*AuthService, error) { + secret, err := loadOrCreateSecret(database) + if err != nil { + return nil, err + } + return &AuthService{db: database, secret: secret}, nil +} + +func loadOrCreateSecret(database *db.DB) ([]byte, error) { + value, ok, err := database.GetSetting(signingKeySetting) + if err != nil { + return nil, err + } + if ok { + return hex.DecodeString(value) + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("generate signing key: %w", err) + } + if err := database.SetSetting(signingKeySetting, hex.EncodeToString(buf)); err != nil { + return nil, err + } + return buf, nil +} + +// EnsureAdmin creates an initial admin if none exists. +func (a *AuthService) EnsureAdmin(username, password string) (bool, error) { + count, err := a.db.CountAdmins() + if err != nil { + return false, err + } + if count > 0 { + return false, nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return false, fmt.Errorf("hash password: %w", err) + } + if _, err := a.db.CreateAdmin(username, string(hash)); err != nil { + return false, err + } + return true, nil +} + +func (a *AuthService) Authenticate(username, password string) bool { + admin, err := a.db.GetAdminByUsername(username) + if err != nil { + // Run a dummy hash comparison to reduce timing side-channels. + _ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password)) + return false + } + return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil +} + +func (a *AuthService) issueToken(username string) string { + expiry := time.Now().Add(sessionTTL).Unix() + payload := fmt.Sprintf("%s|%d", username, expiry) + sig := a.sign(payload) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig +} + +func (a *AuthService) sign(payload string) string { + mac := hmac.New(sha256.New, a.secret) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (a *AuthService) verifyToken(token string) (string, bool) { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return "", false + } + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + payload := string(payloadBytes) + expected := a.sign(payload) + if subtle.ConstantTimeCompare([]byte(expected), []byte(parts[1])) != 1 { + return "", false + } + segs := strings.SplitN(payload, "|", 2) + if len(segs) != 2 { + return "", false + } + expiry, err := strconv.ParseInt(segs[1], 10, 64) + if err != nil || time.Now().Unix() > expiry { + return "", false + } + return segs[0], true +} + +func (a *AuthService) setSessionCookie(w http.ResponseWriter, username string) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: a.issueToken(username), + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(sessionTTL), + MaxAge: int(sessionTTL.Seconds()), + }) +} + +func (a *AuthService) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +func (a *AuthService) currentUser(r *http.Request) (string, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return "", false + } + return a.verifyToken(cookie.Value) +} + +// RequireAuth wraps API handlers that must be authenticated. +func (s *Server) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.Auth == nil { + next.ServeHTTP(w, r) + return + } + if _, ok := s.Auth.currentUser(r); !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + next.ServeHTTP(w, r) + }) +} + +type Server struct { + DB *db.DB + Engine *engine.Engine + AllowedRoots []string + Auth *AuthService +} + +type Options struct { + AllowedRoots []string + Auth *AuthService +} + +func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server { + return &Server{ + DB: database, + Engine: eng, + AllowedRoots: opts.AllowedRoots, + Auth: opts.Auth, + } +} + +func (s *Server) Handler() http.Handler { + return NewRouter(s) +} diff --git a/internal/web/embed.go b/internal/web/embed.go new file mode 100644 index 0000000..29c8403 --- /dev/null +++ b/internal/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed dist/* +var Dist embed.FS diff --git a/internal/web/handlers_apply.go b/internal/web/handlers_apply.go new file mode 100644 index 0000000..9398b87 --- /dev/null +++ b/internal/web/handlers_apply.go @@ -0,0 +1,35 @@ +package web + +import ( + "net/http" +) + +func (s *Server) handleListDirty(w http.ResponseWriter, r *http.Request) { + modules, err := s.Engine.DirtyList(r.Context(), s.DB) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"modules": modules}) +} + +func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { + result, err := s.Engine.ApplyAll(r.Context(), s.DB) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + "results": result.Results, + }) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleApplyLog(w http.ResponseWriter, r *http.Request) { + entries, err := s.DB.ListApplyLog(100) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"entries": entries}) +} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go new file mode 100644 index 0000000..eecf22f --- /dev/null +++ b/internal/web/handlers_auth.go @@ -0,0 +1,49 @@ +package web + +import ( + "encoding/json" + "net/http" +) + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if s.Auth == nil { + writeError(w, http.StatusServiceUnavailable, "auth not configured") + return + } + defer r.Body.Close() + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if !s.Auth.Authenticate(req.Username, req.Password) { + writeError(w, http.StatusUnauthorized, "invalid credentials") + return + } + s.Auth.setSessionCookie(w, req.Username) + writeJSON(w, http.StatusOK, map[string]any{"username": req.Username}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if s.Auth != nil { + s.Auth.clearSessionCookie(w) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { + if s.Auth == nil { + writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "username": ""}) + return + } + username, ok := s.Auth.currentUser(r) + writeJSON(w, http.StatusOK, map[string]any{ + "authenticated": ok, + "username": username, + }) +} diff --git a/internal/web/handlers_nfs.go b/internal/web/handlers_nfs.go new file mode 100644 index 0000000..700ef61 --- /dev/null +++ b/internal/web/handlers_nfs.go @@ -0,0 +1,148 @@ +package web + +import ( + "io" + "net/http" + "strings" + + "encoding/json" + + "github.com/go-chi/chi/v5" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/modules/nfs" + "github.com/darroyo/nasctl/internal/system" +) + +type nfsExportRequest struct { + Path string `json:"path"` + Clients []string `json:"clients"` + Options string `json:"options"` +} + +func (req nfsExportRequest) validate(allowedRoots []string) error { + if err := system.ValidatePathAllowed(req.Path, allowedRoots); err != nil { + return err + } + for _, client := range req.Clients { + if err := system.ValidateNFSClient(client); err != nil { + return err + } + } + if strings.TrimSpace(req.Options) == "" { + return nil + } + return system.ValidateNFSOptions(req.Options) +} + +func (req nfsExportRequest) toModel() db.NFSExport { + options := strings.TrimSpace(req.Options) + if options == "" { + options = "rw,sync,no_root_squash" + } + return db.NFSExport{ + Path: req.Path, + Clients: req.Clients, + Options: options, + } +} + +func (s *Server) handleListNFSExports(w http.ResponseWriter, r *http.Request) { + exports, err := s.DB.ListNFSExports() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"exports": exports}) +} + +func (s *Server) handleGetNFSExport(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + export, err := s.DB.GetNFSExport(id) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, export) +} + +func (s *Server) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) { + req, err := decodeNFSExportRequest(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := req.validate(s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + export, err := s.DB.CreateNFSExport(req.toModel()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.DB.MarkDirty(nfs.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, export) +} + +func (s *Server) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + req, err := decodeNFSExportRequest(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := req.validate(s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + export, err := s.DB.UpdateNFSExport(id, req.toModel()) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err := s.DB.MarkDirty(nfs.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, export) +} + +func (s *Server) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := s.DB.DeleteNFSExport(id); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err := s.DB.MarkDirty(nfs.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func decodeNFSExportRequest(body io.ReadCloser) (nfsExportRequest, error) { + defer body.Close() + var req nfsExportRequest + if err := json.NewDecoder(body).Decode(&req); err != nil { + return nfsExportRequest{}, err + } + return req, nil +} diff --git a/internal/web/handlers_samba.go b/internal/web/handlers_samba.go new file mode 100644 index 0000000..d398a58 --- /dev/null +++ b/internal/web/handlers_samba.go @@ -0,0 +1,169 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/modules/samba" + "github.com/darroyo/nasctl/internal/system" +) + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +type sambaShareRequest struct { + Name string `json:"name"` + Path string `json:"path"` + Comment string `json:"comment"` + ReadOnly bool `json:"read_only"` + GuestOK bool `json:"guest_ok"` + ValidUsers []string `json:"valid_users"` + ValidGroups []string `json:"valid_groups"` +} + +func (req sambaShareRequest) validate(allowedRoots []string) error { + if err := system.ValidateShareName(req.Name); err != nil { + return err + } + if err := system.ValidatePathAllowed(req.Path, allowedRoots); err != nil { + return err + } + for _, user := range req.ValidUsers { + if err := system.ValidateUsername(user); err != nil { + return err + } + } + return nil +} + +func (req sambaShareRequest) toModel() db.SambaShare { + return db.SambaShare{ + Name: req.Name, + Path: req.Path, + Comment: req.Comment, + ReadOnly: req.ReadOnly, + GuestOK: req.GuestOK, + ValidUsers: req.ValidUsers, + ValidGroups: req.ValidGroups, + } +} + +func (s *Server) handleListSambaShares(w http.ResponseWriter, r *http.Request) { + shares, err := s.DB.ListSambaShares() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"shares": shares}) +} + +func (s *Server) handleGetSambaShare(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + share, err := s.DB.GetSambaShare(id) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, share) +} + +func (s *Server) handleCreateSambaShare(w http.ResponseWriter, r *http.Request) { + req, err := decodeSambaShareRequest(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := req.validate(s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + share, err := s.DB.CreateSambaShare(req.toModel()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.DB.MarkDirty(samba.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, share) +} + +func (s *Server) handleUpdateSambaShare(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + req, err := decodeSambaShareRequest(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := req.validate(s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + share, err := s.DB.UpdateSambaShare(id, req.toModel()) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err := s.DB.MarkDirty(samba.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, share) +} + +func (s *Server) handleDeleteSambaShare(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.DB.DeleteSambaShare(id); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err := s.DB.MarkDirty(samba.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func decodeSambaShareRequest(body io.ReadCloser) (sambaShareRequest, error) { + defer body.Close() + var req sambaShareRequest + if err := json.NewDecoder(body).Decode(&req); err != nil { + return sambaShareRequest{}, err + } + return req, nil +} + +func parseID(raw string) (int64, error) { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + return 0, err + } + return id, nil +} diff --git a/internal/web/handlers_system.go b/internal/web/handlers_system.go new file mode 100644 index 0000000..9850e57 --- /dev/null +++ b/internal/web/handlers_system.go @@ -0,0 +1,75 @@ +package web + +import ( + "net/http" + "strings" + "syscall" + + "github.com/darroyo/nasctl/internal/system" +) + +type diskUsage struct { + Path string `json:"path"` + TotalBytes uint64 `json:"total_bytes"` + FreeBytes uint64 `json:"free_bytes"` + UsedBytes uint64 `json:"used_bytes"` + UsedPercent float64 `json:"used_percent"` +} + +type serviceStatus struct { + Name string `json:"name"` + Active bool `json:"active"` + State string `json:"state"` +} + +func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) { + status := map[string]any{ + "disks": collectDiskUsage(), + "services": collectServiceStatus(r), + } + writeJSON(w, http.StatusOK, status) +} + +func collectDiskUsage() []diskUsage { + paths := []string{"/"} + var usages []diskUsage + for _, path := range paths { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + continue + } + total := stat.Blocks * uint64(stat.Bsize) + free := stat.Bavail * uint64(stat.Bsize) + used := total - free + var pct float64 + if total > 0 { + pct = float64(used) / float64(total) * 100 + } + usages = append(usages, diskUsage{ + Path: path, + TotalBytes: total, + FreeBytes: free, + UsedBytes: used, + UsedPercent: pct, + }) + } + return usages +} + +func collectServiceStatus(r *http.Request) []serviceStatus { + services := []string{"smbd", "nfs-server"} + var statuses []serviceStatus + for _, name := range services { + stdout, _, err := system.Run(r.Context(), "systemctl", "is-active", name) + state := strings.TrimSpace(stdout) + if state == "" && err != nil { + state = "unknown" + } + statuses = append(statuses, serviceStatus{ + Name: name, + Active: state == "active", + State: state, + }) + } + return statuses +} diff --git a/internal/web/handlers_users.go b/internal/web/handlers_users.go new file mode 100644 index 0000000..68e9c51 --- /dev/null +++ b/internal/web/handlers_users.go @@ -0,0 +1,156 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/darroyo/nasctl/internal/db" + "github.com/darroyo/nasctl/internal/modules/users" + "github.com/darroyo/nasctl/internal/system" +) + +type userRequest struct { + Username string `json:"username"` + Groups []string `json:"groups"` + SMBEnabled bool `json:"smb_enabled"` + Disabled bool `json:"disabled"` + // Password is write-only; it is never returned or logged. + Password string `json:"password"` +} + +func (req userRequest) validate() error { + if err := system.ValidateUsername(req.Username); err != nil { + return err + } + for _, group := range req.Groups { + if err := system.ValidateUsername(group); err != nil { + return err + } + } + return nil +} + +func (req userRequest) toModel() db.User { + return db.User{ + Username: req.Username, + Groups: req.Groups, + SMBEnabled: req.SMBEnabled, + Disabled: req.Disabled, + PendingPassword: req.Password, + } +} + +func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { + list, err := s.DB.ListUsers() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"users": list}) +} + +func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + user, err := s.DB.GetUser(id) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, user) +} + +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { + req, err := decodeUserRequest(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := req.validate(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + user, err := s.DB.CreateUser(req.toModel()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.DB.MarkDirty(users.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, user) +} + +func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + req, err := decodeUserRequest(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := req.validate(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + existing, err := s.DB.GetUser(id) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + model := req.toModel() + // Preserve a previously stored pending password if none is supplied now. + if model.PendingPassword == "" { + model.PendingPassword = existing.PendingPassword + } + + user, err := s.DB.UpdateUser(id, model) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err := s.DB.MarkDirty(users.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, user) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := s.DB.DeleteUser(id); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err := s.DB.MarkDirty(users.ModuleName); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func decodeUserRequest(body io.ReadCloser) (userRequest, error) { + defer body.Close() + var req userRequest + if err := json.NewDecoder(body).Decode(&req); err != nil { + return userRequest{}, err + } + return req, nil +} diff --git a/internal/web/router.go b/internal/web/router.go new file mode 100644 index 0000000..40cc5b0 --- /dev/null +++ b/internal/web/router.go @@ -0,0 +1,105 @@ +package web + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func NewRouter(s *Server) chi.Router { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Route("/api", func(api chi.Router) { + // Public auth endpoints. + api.Post("/auth/login", s.handleLogin) + api.Post("/auth/logout", s.handleLogout) + api.Get("/auth/status", s.handleAuthStatus) + + // Everything below requires authentication. + api.Group(func(protected chi.Router) { + protected.Use(s.RequireAuth) + + protected.Get("/dirty", s.handleListDirty) + protected.Post("/apply", s.handleApply) + protected.Get("/apply/log", s.handleApplyLog) + protected.Get("/system/status", s.handleSystemStatus) + + protected.Route("/samba/shares", func(shares chi.Router) { + shares.Get("/", s.handleListSambaShares) + shares.Post("/", s.handleCreateSambaShare) + shares.Route("/{id}", func(item chi.Router) { + item.Get("/", s.handleGetSambaShare) + item.Put("/", s.handleUpdateSambaShare) + item.Delete("/", s.handleDeleteSambaShare) + }) + }) + + protected.Route("/nfs/exports", func(exports chi.Router) { + exports.Get("/", s.handleListNFSExports) + exports.Post("/", s.handleCreateNFSExport) + exports.Route("/{id}", func(item chi.Router) { + item.Get("/", s.handleGetNFSExport) + item.Put("/", s.handleUpdateNFSExport) + item.Delete("/", s.handleDeleteNFSExport) + }) + }) + + protected.Route("/users", func(users chi.Router) { + users.Get("/", s.handleListUsers) + users.Post("/", s.handleCreateUser) + users.Route("/{id}", func(item chi.Router) { + item.Get("/", s.handleGetUser) + item.Put("/", s.handleUpdateUser) + item.Delete("/", s.handleDeleteUser) + }) + }) + }) + }) + + r.NotFound(s.handleStatic) + r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + }) + + return r +} + +func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + + data, err := Dist.ReadFile("dist/" + path) + if err != nil { + if path != "index.html" { + data, err = Dist.ReadFile("dist/index.html") + } + if err != nil { + http.NotFound(w, r) + return + } + } + + if strings.HasSuffix(path, ".html") { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } else if strings.HasSuffix(path, ".js") { + w.Header().Set("Content-Type", "application/javascript") + } else if strings.HasSuffix(path, ".css") { + w.Header().Set("Content-Type", "text/css") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..891352a --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +install -d /var/lib/nasctl +install -m 0755 /usr/local/bin/nasctl /usr/local/bin/nasctl +install -m 0644 /etc/systemd/system/nasctl.service /etc/systemd/system/nasctl.service +systemctl daemon-reload +systemctl enable nasctl.service + +echo "nasctl installed. Start with: systemctl start nasctl" diff --git a/systemd/nasctl.service b/systemd/nasctl.service new file mode 100644 index 0000000..287a9a9 --- /dev/null +++ b/systemd/nasctl.service @@ -0,0 +1,12 @@ +[Unit] +Description=nasctl NAS control plane +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/nasctl --addr :8080 --db /var/lib/nasctl/nasctl.db +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a97fc3d --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + nasctl + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..ff3f8e1 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2538 @@ +{ + "name": "nasctl-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nasctl-web", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "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/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..6a93261 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "nasctl-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0206e4f --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { api } from "./api"; +import { DirtyProvider } from "./DirtyContext"; +import Layout from "./components/Layout"; +import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; +import Users from "./pages/Users"; +import Samba from "./pages/Samba"; +import Nfs from "./pages/Nfs"; +import Log from "./pages/Log"; + +type AuthState = { loading: boolean; authenticated: boolean; username: string }; + +export default function App() { + const [auth, setAuth] = useState({ loading: true, authenticated: false, username: "" }); + + useEffect(() => { + api + .status() + .then((res) => setAuth({ loading: false, authenticated: res.authenticated, username: res.username })) + .catch(() => setAuth({ loading: false, authenticated: false, username: "" })); + }, []); + + if (auth.loading) { + return
Cargando...
; + } + + if (!auth.authenticated) { + return ( + + setAuth({ loading: false, authenticated: true, username })} />} + /> + } /> + + ); + } + + return ( + + + setAuth({ loading: false, authenticated: false, username: "" })} + /> + } + > + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/web/src/DirtyContext.tsx b/web/src/DirtyContext.tsx new file mode 100644 index 0000000..7d74312 --- /dev/null +++ b/web/src/DirtyContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useContext, useCallback, useEffect, useState, ReactNode } from "react"; +import { api, DirtyModule } from "./api"; + +interface DirtyContextValue { + modules: DirtyModule[]; + refresh: () => Promise; + applying: boolean; + apply: () => Promise; + error: string | null; +} + +const DirtyContext = createContext(undefined); + +export function DirtyProvider({ children }: { children: ReactNode }) { + const [modules, setModules] = useState([]); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const res = await api.dirty(); + setModules(res.modules ?? []); + } catch { + // ignore transient errors + } + }, []); + + const apply = useCallback(async () => { + setApplying(true); + setError(null); + try { + const res = await api.apply(); + const failed = res.results.filter((r) => r.error); + if (failed.length > 0) { + setError(failed.map((f) => `${f.module}: ${f.error}`).join("; ")); + } + } catch (e) { + setError(e instanceof Error ? e.message : "apply failed"); + } finally { + setApplying(false); + await refresh(); + } + }, [refresh]); + + useEffect(() => { + refresh(); + }, [refresh]); + + return ( + + {children} + + ); +} + +export function useDirty() { + const ctx = useContext(DirtyContext); + if (!ctx) throw new Error("useDirty must be used within DirtyProvider"); + return ctx; +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..ab5f473 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,121 @@ +export interface SambaShare { + id: number; + name: string; + path: string; + comment: string; + read_only: boolean; + guest_ok: boolean; + valid_users: string[]; + valid_groups: string[]; +} + +export interface NFSExport { + id: number; + path: string; + clients: string[]; + options: string; +} + +export interface User { + id: number; + username: string; + groups: string[]; + smb_enabled: boolean; + disabled: boolean; +} + +export interface DirtyModule { + module: string; + marked_at: string; +} + +export interface ApplyLogEntry { + id: number; + module: string; + message: string; + success: boolean; + created_at: string; +} + +export interface DiskUsage { + path: string; + total_bytes: number; + free_bytes: number; + used_bytes: number; + used_percent: number; +} + +export interface ServiceStatus { + name: string; + active: boolean; + state: string; +} + +export interface SystemStatus { + disks: DiskUsage[]; + services: ServiceStatus[]; +} + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`/api${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 204) { + return undefined as T; + } + const text = await res.text(); + const data = text ? JSON.parse(text) : {}; + if (!res.ok) { + throw new ApiError(res.status, data.error || res.statusText); + } + return data as T; +} + +export const api = { + // auth + status: () => request<{ authenticated: boolean; username: string }>("GET", "/auth/status"), + login: (username: string, password: string) => + request<{ username: string }>("POST", "/auth/login", { username, password }), + logout: () => request<{ ok: boolean }>("POST", "/auth/logout"), + + // dirty / apply + dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"), + apply: () => request<{ results: { module: string; applied: boolean; error?: string }[] }>("POST", "/apply"), + applyLog: () => request<{ entries: ApplyLogEntry[] }>("GET", "/apply/log"), + systemStatus: () => request("GET", "/system/status"), + + // samba + listShares: () => request<{ shares: SambaShare[] | null }>("GET", "/samba/shares/"), + createShare: (s: Partial) => request("POST", "/samba/shares/", s), + updateShare: (id: number, s: Partial) => request("PUT", `/samba/shares/${id}/`, s), + deleteShare: (id: number) => request("DELETE", `/samba/shares/${id}/`), + + // nfs + listExports: () => request<{ exports: NFSExport[] | null }>("GET", "/nfs/exports/"), + createExport: (e: Partial) => request("POST", "/nfs/exports/", e), + updateExport: (id: number, e: Partial) => request("PUT", `/nfs/exports/${id}/`, e), + deleteExport: (id: number) => request("DELETE", `/nfs/exports/${id}/`), + + // users + listUsers: () => request<{ users: User[] | null }>("GET", "/users/"), + createUser: (u: Partial & { password?: string }) => request("POST", "/users/", u), + updateUser: (id: number, u: Partial & { password?: string }) => request("PUT", `/users/${id}/`, u), + deleteUser: (id: number) => request("DELETE", `/users/${id}/`), +}; + +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} diff --git a/web/src/components/DirtyBanner.tsx b/web/src/components/DirtyBanner.tsx new file mode 100644 index 0000000..c4aa77e --- /dev/null +++ b/web/src/components/DirtyBanner.tsx @@ -0,0 +1,29 @@ +import { useDirty } from "../DirtyContext"; + +export default function DirtyBanner() { + const { modules, apply, applying, error } = useDirty(); + if (modules.length === 0 && !error) return null; + + return ( +
+ {modules.length > 0 && ( +
+ + Tienes cambios sin aplicar:{" "} + + {modules.map((m) => m.module).join(", ")} + + + +
+ )} + {error && ( +
+ Error al aplicar: {error} +
+ )} +
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..9d53bc4 --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,59 @@ +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { api } from "../api"; +import DirtyBanner from "./DirtyBanner"; + +const navItems = [ + { to: "/", label: "Dashboard", end: true }, + { to: "/users", label: "Usuarios" }, + { to: "/samba", label: "SMB / Samba" }, + { to: "/nfs", label: "NFS" }, + { to: "/log", label: "Historial" }, +]; + +export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) { + const navigate = useNavigate(); + + async function handleLogout() { + await api.logout(); + onLogout(); + navigate("/login"); + } + + return ( +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx new file mode 100644 index 0000000..451aa3d --- /dev/null +++ b/web/src/components/Modal.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from "react"; + +export default function Modal({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: ReactNode; +}) { + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ {children} +
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..1c1cc5c --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,33 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-slate-950 text-slate-100 antialiased; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50; + } + .btn-primary { + @apply btn bg-brand-600 text-white hover:bg-brand-700; + } + .btn-ghost { + @apply btn bg-slate-800 text-slate-200 hover:bg-slate-700; + } + .btn-danger { + @apply btn bg-red-600 text-white hover:bg-red-700; + } + .input { + @apply w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500; + } + .label { + @apply mb-1 block text-sm font-medium text-slate-300; + } + .card { + @apply rounded-lg border border-slate-800 bg-slate-900/60 p-5 shadow; + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..fe05248 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..1c984db --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; +import { api, formatBytes, SystemStatus } from "../api"; +import { useDirty } from "../DirtyContext"; + +export default function Dashboard() { + const [status, setStatus] = useState(null); + const { modules } = useDirty(); + + useEffect(() => { + api.systemStatus().then(setStatus).catch(() => setStatus(null)); + }, []); + + return ( +
+

Dashboard

+ +
+
+

+ Uso de disco +

+ {status?.disks?.length ? ( +
+ {status.disks.map((d) => ( +
+
+ {d.path} + + {formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)} + +
+
+
+
+
+ ))} +
+ ) : ( +

Sin datos de disco.

+ )} +
+ +
+

+ Servicios +

+ {status?.services?.length ? ( +
    + {status.services.map((s) => ( +
  • + {s.name} + + {s.state} + +
  • + ))} +
+ ) : ( +

Sin datos de servicios.

+ )} +
+
+ +
+

+ Cambios pendientes +

+ {modules.length > 0 ? ( +

+ Módulos con cambios sin aplicar: {modules.map((m) => m.module).join(", ")} +

+ ) : ( +

Todo aplicado. No hay cambios pendientes.

+ )} +
+
+ ); +} diff --git a/web/src/pages/Log.tsx b/web/src/pages/Log.tsx new file mode 100644 index 0000000..00231f8 --- /dev/null +++ b/web/src/pages/Log.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { api, ApplyLogEntry } from "../api"; + +export default function Log() { + const [entries, setEntries] = useState([]); + + useEffect(() => { + api.applyLog().then((res) => setEntries(res.entries ?? [])).catch(() => setEntries([])); + }, []); + + return ( +
+

Historial de aplicaciones

+
+ + + + + + + + + + + {entries.map((e) => ( + + + + + + + ))} + {entries.length === 0 && ( + + + + )} + +
FechaMóduloResultadoMensaje
{new Date(e.created_at).toLocaleString()}{e.module} + {e.success ? ( + OK + ) : ( + Error + )} + {e.message}
+ Sin registros todavía. +
+
+
+ ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..acb0090 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,52 @@ +import { FormEvent, useState } from "react"; +import { api } from "../api"; + +export default function Login({ onLoggedIn }: { onLoggedIn: (username: string) => void }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + try { + const res = await api.login(username, password); + onLoggedIn(res.username); + } catch { + setError("Credenciales inválidas"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ nasctl +
+ {error && ( +
{error}
+ )} +
+ + setUsername(e.target.value)} autoFocus /> +
+
+ + setPassword(e.target.value)} + /> +
+ +
+
+ ); +} diff --git a/web/src/pages/Nfs.tsx b/web/src/pages/Nfs.tsx new file mode 100644 index 0000000..6007788 --- /dev/null +++ b/web/src/pages/Nfs.tsx @@ -0,0 +1,151 @@ +import { FormEvent, useEffect, useState } from "react"; +import { api, NFSExport } from "../api"; +import { useDirty } from "../DirtyContext"; +import Modal from "../components/Modal"; + +const empty: Partial = { + path: "", + clients: [], + options: "rw,sync,no_root_squash", +}; + +export default function Nfs() { + const [exports, setExports] = useState([]); + const [editing, setEditing] = useState | null>(null); + const [error, setError] = useState(null); + const { refresh } = useDirty(); + + async function load() { + const res = await api.listExports(); + setExports(res.exports ?? []); + } + + useEffect(() => { + load(); + }, []); + + async function save(e: FormEvent) { + e.preventDefault(); + if (!editing) return; + setError(null); + try { + if (editing.id) { + await api.updateExport(editing.id, editing); + } else { + await api.createExport(editing); + } + setEditing(null); + await load(); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al guardar"); + } + } + + async function remove(id: number) { + if (!confirm("¿Eliminar este export?")) return; + await api.deleteExport(id); + await load(); + await refresh(); + } + + return ( +
+
+

Exports NFS

+ +
+ +
+ + + + + + + + + + + {exports.map((x) => ( + + + + + + + ))} + {exports.length === 0 && ( + + + + )} + +
PathClientesOpciones
{x.path}{x.clients.join(", ") || "*"}{x.options} + + +
+ No hay exports configurados. +
+
+ + {editing && ( + setEditing(null)}> +
+ {error && ( +
{error}
+ )} +
+ + setEditing({ ...editing, path: e.target.value })} + /> +
+
+ + + setEditing({ + ...editing, + clients: e.target.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + }) + } + /> +
+
+ + setEditing({ ...editing, options: e.target.value })} + /> +

+ Ej: rw,sync,no_root_squash · ro,async,root_squash +

+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/web/src/pages/Samba.tsx b/web/src/pages/Samba.tsx new file mode 100644 index 0000000..36711fb --- /dev/null +++ b/web/src/pages/Samba.tsx @@ -0,0 +1,181 @@ +import { FormEvent, useEffect, useState } from "react"; +import { api, SambaShare } from "../api"; +import { useDirty } from "../DirtyContext"; +import Modal from "../components/Modal"; + +const empty: Partial = { + name: "", + path: "", + comment: "", + read_only: false, + guest_ok: false, + valid_users: [], + valid_groups: [], +}; + +export default function Samba() { + const [shares, setShares] = useState([]); + const [editing, setEditing] = useState | null>(null); + const [error, setError] = useState(null); + const { refresh } = useDirty(); + + async function load() { + const res = await api.listShares(); + setShares(res.shares ?? []); + } + + useEffect(() => { + load(); + }, []); + + async function save(e: FormEvent) { + e.preventDefault(); + if (!editing) return; + setError(null); + try { + if (editing.id) { + await api.updateShare(editing.id, editing); + } else { + await api.createShare(editing); + } + setEditing(null); + await load(); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al guardar"); + } + } + + async function remove(id: number) { + if (!confirm("¿Eliminar este share?")) return; + await api.deleteShare(id); + await load(); + await refresh(); + } + + return ( +
+
+

Shares SMB / Samba

+ +
+ +
+ + + + + + + + + + + + + {shares.map((s) => ( + + + + + + + + + ))} + {shares.length === 0 && ( + + + + )} + +
NombrePathModoInvitadoUsuarios
{s.name}{s.path}{s.read_only ? "Solo lectura" : "Lectura/Escritura"}{s.guest_ok ? "Sí" : "No"}{s.valid_users.join(", ") || "—"} + + +
+ No hay shares configurados. +
+
+ + {editing && ( + setEditing(null)}> +
+ {error && ( +
{error}
+ )} +
+ + setEditing({ ...editing, name: e.target.value })} + /> +
+
+ + setEditing({ ...editing, path: e.target.value })} + /> +
+
+ + setEditing({ ...editing, comment: e.target.value })} + /> +
+
+ + + setEditing({ + ...editing, + valid_users: e.target.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + }) + } + /> +
+
+ + +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx new file mode 100644 index 0000000..a3f14a9 --- /dev/null +++ b/web/src/pages/Users.tsx @@ -0,0 +1,185 @@ +import { FormEvent, useEffect, useState } from "react"; +import { api, User } from "../api"; +import { useDirty } from "../DirtyContext"; +import Modal from "../components/Modal"; + +type EditUser = Partial & { password?: string }; + +const empty: EditUser = { + username: "", + groups: [], + smb_enabled: false, + disabled: false, + password: "", +}; + +export default function Users() { + const [users, setUsers] = useState([]); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const { refresh } = useDirty(); + + async function load() { + const res = await api.listUsers(); + setUsers(res.users ?? []); + } + + useEffect(() => { + load(); + }, []); + + async function save(e: FormEvent) { + e.preventDefault(); + if (!editing) return; + setError(null); + try { + if (editing.id) { + await api.updateUser(editing.id, editing); + } else { + await api.createUser(editing); + } + setEditing(null); + await load(); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al guardar"); + } + } + + async function remove(id: number) { + if (!confirm("¿Eliminar este usuario? Se ejecutará userdel al aplicar.")) return; + await api.deleteUser(id); + await load(); + await refresh(); + } + + return ( +
+
+

Usuarios del sistema

+ +
+ +
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
UsuarioGruposSMBEstado
{u.username}{u.groups.join(", ") || "—"} + {u.smb_enabled ? ( + + Habilitado + + ) : ( + + Deshabilitado + + )} + {u.disabled ? "Bloqueado" : "Activo"} + + +
+ No hay usuarios configurados. +
+
+ + {editing && ( + setEditing(null)}> +
+ {error && ( +
{error}
+ )} +
+ + setEditing({ ...editing, username: e.target.value })} + /> +
+
+ + + setEditing({ + ...editing, + groups: e.target.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + }) + } + /> +
+
+ + setEditing({ ...editing, password: e.target.value })} + /> +
+
+ + +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..011ed27 --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,17 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + brand: { + 50: "#eff6ff", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", + }, + }, + }, + }, + plugins: [], +}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8b2578a --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild" + }, + "include": ["vite.config.ts"] +} diff --git a/web/tsconfig.node.tsbuildinfo b/web/tsconfig.node.tsbuildinfo new file mode 100644 index 0000000..e895b38 --- /dev/null +++ b/web/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.d.ts","./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[40],[40,41,42,43,44],[40,42],[51],[48,49,50],[39,45],[31],[29,31],[20,28,29,30,32,34],[18],[21,26,31,34],[17,34],[21,22,25,26,27,34],[21,22,23,25,26,34],[18,19,20,21,22,26,27,28,30,31,32,34],[34],[16,18,19,20,21,22,23,25,26,27,28,29,30,31,32,33],[16,34],[21,23,24,26,27,34],[25,34],[26,27,31,34],[19,29],[9,38],[8,9],[9,10,11,12,13,14,15,35,36,37,38],[11,12,13,14],[11,12,13],[11],[12],[9],[39,46]],"fileInfos":[{"version":"a7297ff837fcdf174a9524925966429eb8e5feecc2cc55cc06574e6b092c1eaa","impliedFormat":1},{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"e3cf0611709328b449ec13f8c436712d62003620ce480139fae46ce001c2ee9f","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},"8550cbdea4bfff92255de9b39cae325440371187d9d5426c5dc1f23242a8ddc9",{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1}],"root":[47],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"strict":true},"referencedMap":[[42,1],[45,2],[41,1],[43,3],[44,1],[52,4],[51,5],[46,6],[32,7],[30,8],[31,9],[19,10],[20,8],[27,11],[18,12],[23,13],[24,14],[29,15],[35,16],[34,17],[17,18],[25,19],[26,20],[21,21],[28,7],[22,22],[10,23],[9,24],[39,25],[36,26],[14,27],[12,28],[13,29],[38,30],[47,31]],"affectedFilesPendingEmit":[[47,17]],"emitSignatures":[47],"version":"5.9.3"} \ No newline at end of file diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..0c25b91 --- /dev/null +++ b/web/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/dirtycontext.tsx","./src/api.ts","./src/main.tsx","./src/components/dirtybanner.tsx","./src/components/layout.tsx","./src/components/modal.tsx","./src/pages/dashboard.tsx","./src/pages/log.tsx","./src/pages/login.tsx","./src/pages/nfs.tsx","./src/pages/samba.tsx","./src/pages/users.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..0a98b04 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Build output goes into internal/web/dist so it is embedded into the Go binary. +export default defineConfig({ + plugins: [react()], + build: { + outDir: "../internal/web/dist", + emptyOutDir: true, + }, + server: { + proxy: { + "/api": "http://localhost:8080", + }, + }, +});