From 8e08c73f60fb6a64bbee70bc421b4d0b593c46ab Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Tue, 7 Jul 2026 15:03:22 -0400 Subject: [PATCH] feat: complete SyncServer implementation Full-stack Go monolith with embedded React frontend for orchestrating rsync-over-SSH file synchronization with Wake-on-LAN support. Features: - JWT auth (HS256) with bcrypt password hashing - CRUD for machines (with WoL config) and sync_pairs - Ed25519 SSH key generation and known_hosts management - WoL magic packet sender + TCP-connect waiter with backoff - Sync engine: rsync subprocess, per-pair job queue, progress parsing - Homebrew cron parser for scheduled syncs - SSE stream for live job status (queued/waking_up/running/success/failed) - React+TS+Vite+Tailwind SPA embedded via embed.FS - Debian packaging with systemd unit, postinst/prerm/postrm Tech stack: - Go 1.22+ (CGO_ENABLED=0, pure SQLite via modernc.org/sqlite) - chi router for HTTP API - TypeScript + React 18 + Tailwind CSS frontend - Cross-compiled to Linux amd64 for Proxmox LXC deployment Tests: wol (MAC parsing, magic packet), syncengine/queue, scheduler/cron --- .gitignore | 184 +- AGENTS.md | 76 + Makefile | 36 + README.md | 299 +- cmd/server/main.go | 124 + go.mod | 20 + go.sum | 30 + internal/api/dto.go | 69 + internal/api/handlers_auth.go | 107 + internal/api/handlers_jobs.go | 167 ++ internal/api/handlers_machines.go | 226 ++ internal/api/handlers_syncpairs.go | 183 ++ internal/api/handlers_ws.go | 64 + internal/api/router.go | 102 + internal/auth/cookies.go | 40 + internal/auth/jwt.go | 69 + internal/auth/middleware.go | 61 + internal/auth/password.go | 16 + internal/auth/seed.go | 37 + internal/config/config.go | 149 + internal/db/db.go | 102 + internal/db/migrations.go | 7 + internal/db/migrations/0001_init.sql | 86 + internal/models/job.go | 145 + internal/models/machine.go | 161 + internal/models/schedule.go | 124 + internal/models/sshkey.go | 80 + internal/models/syncpair.go | 181 ++ internal/models/user.go | 66 + internal/scheduler/cron.go | 153 + internal/scheduler/cron_test.go | 97 + internal/scheduler/scheduler.go | 95 + internal/sshmanager/keys.go | 68 + internal/sshmanager/knownhosts.go | 143 + internal/sshmanager/testconn.go | 107 + internal/syncengine/engine.go | 223 ++ internal/syncengine/parser.go | 76 + internal/syncengine/queue.go | 63 + internal/syncengine/queue_test.go | 45 + internal/syncengine/rsync_runner.go | 157 + internal/webui/embed.go | 43 + internal/wol/waiter.go | 68 + internal/wol/wol.go | 80 + internal/wol/wol_test.go | 72 + packaging/debian/DEBIAN/conffiles | 1 + packaging/debian/DEBIAN/control | 12 + packaging/debian/DEBIAN/postinst | 47 + packaging/debian/DEBIAN/postrm | 21 + packaging/debian/DEBIAN/prerm | 20 + .../lib/systemd/system/syncserver.service | 27 + scripts/build.sh | 32 + scripts/package-deb.sh | 37 + web/index.html | 12 + web/package-lock.json | 2633 +++++++++++++++++ web/package.json | 26 + web/postcss.config.js | 6 + web/src/App.tsx | 35 + web/src/api/client.ts | 67 + web/src/index.css | 3 + web/src/main.tsx | 10 + web/src/pages/Dashboard.tsx | 92 + web/src/pages/JobHistory.tsx | 101 + web/src/pages/Login.tsx | 60 + web/src/pages/Machines.tsx | 117 + web/src/pages/Settings.tsx | 50 + web/src/pages/SyncPairs.tsx | 152 + web/tailwind.config.js | 8 + web/tsconfig.json | 20 + web/vite.config.ts | 11 + 69 files changed, 7949 insertions(+), 152 deletions(-) create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/dto.go create mode 100644 internal/api/handlers_auth.go create mode 100644 internal/api/handlers_jobs.go create mode 100644 internal/api/handlers_machines.go create mode 100644 internal/api/handlers_syncpairs.go create mode 100644 internal/api/handlers_ws.go create mode 100644 internal/api/router.go create mode 100644 internal/auth/cookies.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/seed.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/db/migrations.go create mode 100644 internal/db/migrations/0001_init.sql create mode 100644 internal/models/job.go create mode 100644 internal/models/machine.go create mode 100644 internal/models/schedule.go create mode 100644 internal/models/sshkey.go create mode 100644 internal/models/syncpair.go create mode 100644 internal/models/user.go create mode 100644 internal/scheduler/cron.go create mode 100644 internal/scheduler/cron_test.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/sshmanager/keys.go create mode 100644 internal/sshmanager/knownhosts.go create mode 100644 internal/sshmanager/testconn.go create mode 100644 internal/syncengine/engine.go create mode 100644 internal/syncengine/parser.go create mode 100644 internal/syncengine/queue.go create mode 100644 internal/syncengine/queue_test.go create mode 100644 internal/syncengine/rsync_runner.go create mode 100644 internal/webui/embed.go create mode 100644 internal/wol/waiter.go create mode 100644 internal/wol/wol.go create mode 100644 internal/wol/wol_test.go create mode 100644 packaging/debian/DEBIAN/conffiles create mode 100644 packaging/debian/DEBIAN/control create mode 100755 packaging/debian/DEBIAN/postinst create mode 100755 packaging/debian/DEBIAN/postrm create mode 100755 packaging/debian/DEBIAN/prerm create mode 100644 packaging/debian/lib/systemd/system/syncserver.service create mode 100755 scripts/build.sh create mode 100755 scripts/package-deb.sh 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/api/client.ts 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/JobHistory.tsx create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/pages/Machines.tsx create mode 100644 web/src/pages/Settings.tsx create mode 100644 web/src/pages/SyncPairs.tsx create mode 100644 web/tailwind.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index fbd390a..138a622 100644 --- a/.gitignore +++ b/.gitignore @@ -1,165 +1,47 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins +# Binary output +syncserver +*.deb +dist/ + +# Frontend +web/node_modules/ +web/dist/ +internal/webui/dist/ + +# Data (runtime) +data/ +*.db +*.db-wal +*.db-shm + +# Logs +*.log +logs/ + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ + +# macOS +.DS_Store + +# Go *.exe *.exe~ *.dll *.so *.dylib - -# Test binary, built with `go test -c` *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file go.work go.work.sum -# env file -.env - -# ---> Node -# Logs -logs +# Node *.log npm-debug.log* yarn-debug.log* -yarn-error.log* -lerna-debug.log* .pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2495dd7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# SyncServer — Agent Guidance + +## Project Type + +Go monolith (single binary) with embedded React SPA frontend. Targets Linux amd64 LXC containers on Proxmox. + +## Dev Commands + +```bash +# Local dev build (macOS) +make build-local + +# Production Linux amd64 build +make build # → packaging/debian/usr/bin/syncserver +make deb # → dist/syncserver_VERSION_amd64.deb + +# Run +./syncserver --data-dir ./data --addr :8080 \ + SYNCSERVER_ADMIN_USER=admin SYNCSERVER_ADMIN_PASSWORD=secret + +# Tests +go test ./... +``` + +## Critical Build Order + +1. Frontend must be built FIRST: `cd web && npm run build` +2. Output copied to `internal/webui/dist/` (must exist before Go build) +3. Then: `go build ./cmd/server` + +`scripts/build.sh` does this automatically. Running `go build` without the frontend dist will produce a binary without the web UI. + +## Architecture Notes + +- **Entry point**: `cmd/server/main.go` +- **DB**: `modernc.org/sqlite` — pure Go, CGO_ENABLED=0. Driver name is `"sqlite"` (not `"sqlite3"`). +- **Migrations**: embedded via `//go:embed migrations` in `internal/db/migrations.go`. Must use `migrationsFS` variable, path `"migrations"` (not `"internal/db/migrations"`). +- **SSH keys**: generated at startup if missing; stored in `$DATA_DIR/ssh/id_ed25519`. Only the public key is stored in DB. +- **Sync engine**: uses `os/exec.CommandContext` to run `rsync -e ssh ...`. SSH key is passed via `-i` flag, not via SSH config file. +- **Scheduler**: homebrew cron parser (`m h dom mon dow` format, no seconds). Library `github.com/robfig/cron` is NOT used. +- **SSE**: `GET /api/jobs/stream` streams `text/event-stream`. Clients must handle `event:` field. + +## Frontend Stack + +- TypeScript + React 18 + Vite + Tailwind CSS +- Source: `web/src/`, built output: `web/dist/` +- API client: `web/src/api/client.ts` +- No React Router 6 data loaders; fetch hooks are manual in each page component +- Tests: no frontend test suite yet + +## Key Package Boundaries + +| Package | Responsibility | +|---|---| +| `internal/api/` | HTTP handlers, chi router, DTOs | +| `internal/auth/` | JWT (HS256), bcrypt, middleware | +| `internal/db/` | SQLite connection, migrations runner | +| `internal/models/` | Data access (raw sql) | +| `internal/syncengine/` | Job queue, rsync subprocess, event bus | +| `internal/scheduler/` | Cron parsing, tick loop | +| `internal/sshmanager/` | Ed25519 key generation, known_hosts | +| `internal/wol/` | Magic packet, TCP/ping waiter | +| `internal/webui/` | `embed.FS` for React dist | + +## DB Schema + +Managed via ordered SQL migrations in `internal/db/migrations/`. Apply on startup via `db.RunMigrations()`. Table `schema_migrations` tracks applied versions. + +## Package Script Quirks + +- `scripts/build.sh`: cross-compiles to Linux amd64 via `GOOS=linux GOARCH=amd64`. Version injected via `-ldflags="-X main.version=$VERSION"`. +- `scripts/package-deb.sh`: calls `dpkg-deb --build`. Version must be substituted into `DEBIAN/control` before build (`@@VERSION@@` placeholder). + +## WoL Constraints + +Magic packet uses `SO_BROADCAST` on a plain UDP socket. No raw sockets, no privileged container needed. Requires L2 reachability (same broadcast domain) from the server to the target machine's NIC. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ace647 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: build dev clean deb run test lint + +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GOARCH := amd64 + +build: + GOARCH=$(GOARCH) ./scripts/build.sh + +deb: + ./scripts/package-deb.sh + +clean: + rm -rf dist/ + rm -rf internal/webui/dist/ + go clean + +run: build + ./syncserver --data-dir ./data --addr :8080 + +dev: + cd web && npm run dev & + +test: + go test ./... + +lint: + go vet ./... + +fmt: + go fmt ./... + +# Build for local macOS (not for production) +build-local: + rm -rf web/dist && cd web && npm run build && cd .. + cp -R web/dist internal/webui/dist/ + go build -ldflags="-X main.version=$(VERSION)" -o syncserver ./cmd/server diff --git a/README.md b/README.md index 3abb751..4fd924e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,299 @@ -# move-data-nas +# SyncServer +Self-hosted file sync orchestrator with Wake-on-LAN and web UI. Orchestrates `rsync` over SSH between multiple machines from a single central server. + +## Features + +- **Web UI**: React-based interface to manage machines, sync pairs, and job history +- **Wake-on-LAN**: Power on remote machines before syncing (UDP magic packet) +- **Rsync over SSH**: Efficient file synchronization with progress tracking +- **Scheduled syncs**: Cron-style scheduler for automated backups +- **Live progress**: Server-Sent Events (SSE) for real-time job status +- **Single binary**: No dynamic dependencies, pure Go with embedded SQLite + +## Architecture + +``` +┌─────────────────────────────────────┐ +│ SyncServer (Go binary) │ +│ ┌─────────────────────────────────┐ │ +│ │ Web UI (React, embedded) │ │ +│ │ REST API (chi router) │ │ +│ │ Auth (JWT + bcrypt) │ │ +│ │ Scheduler (cron) │ │ +│ │ Sync Engine (rsync runner) │ │ +│ │ WoL (UDP magic packet) │ │ +│ │ SQLite (embedded) │ │ +│ └─────────────────────────────────┘ │ +└──────────────┬────────────────────────┘ + │ SSH + UDP + ┌─────────┴──────────┐ + ▼ ▼ + Machine A Machine B + (rsync+sshd) (rsync+sshd) +``` + +## Network Requirements for Wake-on-LAN + +**Critical**: The magic packet (WoL) only works when the SyncServer is on the **same L2 network** (same broadcast domain) as the target machine. + +### Proxmox LXC Setup + +- Use **bridge networking** (`vmbr0`) — NOT NAT +- The LXC must share the same VLAN/LAN as the machines it needs to wake +- No special LXC privileges required (works with unprivileged containers) +- `SO_BROADCAST` works in unprivileged LXC containers + +If your LXC is on a different subnet, use the `broadcast_addr` field on the machine to specify the broadcast address of the target machine's subnet (e.g., `192.168.1.255`). + +### BIOS/UEFI Requirements on Target Machines + +- Enable **Wake-on-LAN** in BIOS/UEFI +- Set to "Power On by PCIe" or "Wake by PCI-E" +- Some motherboards require both PCIe and LAN WoL to be enabled + +## Installation + +### Quick Start (Development) + +```bash +git clone https://github.com/syncserver/syncserver +cd syncserver + +# Run with default settings (data in ./data) +SYNCSERVER_ADMIN_USER=admin SYNCSERVER_ADMIN_PASSWORD=secret \ + ./syncserver --addr :8080 + +# Open http://your-server:8080 +``` + +### Production Install (.deb) + +```bash +# Transfer to your Proxmox LXC (Debian/Ubuntu) +scp dist/syncserver_*.deb root@your-lxc:/tmp/ + +# On the LXC: +dpkg -i /tmp/syncserver_*.deb +# postinst will: +# - Create syncserver user +# - Create /var/lib/syncserver/{data,ssh,logs} +# - Install config at /etc/syncserver/config.yaml +# - Enable and start the systemd service + +# To configure the admin user on first run: +# Set environment variables BEFORE first start (in /etc/syncserver/config.yaml or systemd unit): +``` + +### Configuration + +Edit `/etc/syncserver/config.yaml`: + +```yaml +data_dir: /var/lib/syncserver +addr: :8080 + +auth: + jwt_secret: "your-secret-here" # Generate with: openssl rand -hex 32 + jwt_expiry_hours: 24 + +scheduler: + timezone: UTC +``` + +Or use environment variables (override config file): + +```bash +SYNCSERVER_ADMIN_USER=admin SYNCSERVER_ADMIN_PASSWORD=secret \ +SYNCSERVER_JWT_SECRET=your-secret \ +SYNCSERVER_DATA_DIR=/var/lib/syncserver \ +/usr/bin/syncserver --config /etc/syncserver/config.yaml +``` + +## Setup: SSH Keys + +### Step 1: Get the Server's Public Key + +In the UI, go to **Settings** to copy the server's SSH public key. It looks like: + +``` +ssh-ed25519 AAAA... syncserver +``` + +### Step 2: Add the Key to Remote Machines + +On each remote machine, add the public key to `~/.ssh/authorized_keys`: + +```bash +# As root on the remote machine: +echo "ssh-ed25519 AAAA... syncserver" >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +``` + +Or use `ssh-copy-id`: + +```bash +ssh-copy-id -i ~/.ssh/id_ed25519.pub user@remote-machine +# Then replace the key in the remote's authorized_keys with the syncserver key +``` + +### Step 3: Register Machines in the UI + +Go to **Machines** → **Add Machine**: +- Name: e.g., `backup-nas` +- Host: IP or hostname (e.g., `192.168.1.100`) +- SSH Port: `22` +- SSH User: `root` (or your preferred user) +- SSH Key: leave empty to use the server's default key + +For **Wake-on-LAN**: +- Enable "Wake-on-LAN" +- Enter the MAC address (e.g., `AA:BB:CC:DD:EE:FF`) +- Optionally set a broadcast address (leave empty for `255.255.255.255`) +- Configure timeout and check interval + +## Creating Sync Pairs + +Go to **Sync Pairs** → **Add Sync Pair**: + +| Field | Description | +|-------|-------------| +| Name | Descriptive name, e.g., `backup-home` | +| Source | Machine + path (or "Local server" for the syncserver itself) | +| Destination | Machine + path | +| Direction | `push` (source→dest), `pull` (dest→source), `mirror` (bidirectional with delete) | +| Rsync Flags | Default: `-aP` (archive, progress). Add `--delete` for mirror mode. | +| Exclude Patterns | One pattern per line, e.g., `*.tmp`, `node_modules/` | + +## Scheduling + +Add a schedule to any sync pair: + +| Cron Expr | Description | +|-----------|-------------| +| `0 2 * * *` | Daily at 2 AM | +| `0 */6 * * *` | Every 6 hours | +| `0 9-17 * * 1-5` | Business hours, weekdays | +| `*/15 * * * *` | Every 15 minutes | + +All schedules run in **UTC** by default. Set `scheduler.timezone` in config to change. + +## Job States + +| State | Description | +|-------|-------------| +| `queued` | Waiting for a worker | +| `waking_up` | Sending WoL magic packet and waiting for machine | +| `running` | rsync in progress | +| `success` | Completed successfully | +| `failed` | Error during sync | +| `cancelled` | User cancelled the job | + +## Logs + +Job logs are stored at `/var/lib/syncserver/logs/.log`. + +Application logs go to systemd journal: + +```bash +journalctl -u syncserver -f +``` + +## Upgrade + +```bash +# On the LXC: +dpkg -i /tmp/syncserver-new-version.deb +# The service will automatically restart +``` + +Config at `/etc/syncserver/config.yaml` is preserved (conffile). + +## Build from Source + +```bash +# Requires: Go 1.22+, Node 18+, npm + +./scripts/build.sh +# Output: packaging/debian/usr/bin/syncserver + +./scripts/package-deb.sh +# Output: dist/syncserver_VERSION_amd64.deb +``` + +For ARM64: + +```bash +GOARCH=arm64 ./scripts/build.sh +ARCH=arm64 ./scripts/package-deb.sh +``` + +## Data Storage + +``` +/var/lib/syncserver/ +├── data/ +│ └── app.db # SQLite database (machines, sync_pairs, jobs, etc.) +├── ssh/ +│ ├── id_ed25519 # Server's private SSH key +│ ├── id_ed25519.pub # Server's public SSH key +│ └── known_hosts # Host keys of registered machines +└── logs/ + └── .log # Per-job rsync output logs +``` + +**Recommended**: Bind mount `/var/lib/syncserver` to persistent storage outside the LXC root filesystem. This way you can recreate the LXC without losing data. + +In Proxmox LXC config (`/etc/pve/lxc/.conf`): + +``` +mp0: /mnt/data/syncserver,mp=/var/lib/syncserver,backup=0 +``` + +## Troubleshooting + +### WoL doesn't work +1. Verify the LXC is on the same L2 network as the target +2. Check `ip link` in the LXC shows the bridge interface +3. Try: `tcpdump -i udp port 9` in the LXC while triggering WoL +4. Verify WoL is enabled in the target machine's BIOS +5. Try sending WoL from another machine on the same subnet to isolate the issue + +### SSH connection fails +1. Verify the public key is in `~/.ssh/authorized_keys` on the target +2. Check `sshd` is running on the target machine +3. Try manually: `ssh -i /var/lib/syncserver/ssh/id_ed25519 user@host` +4. Check the target's `sshd_config`: `PubkeyAuthentication yes`, `AuthorizedKeysFile .ssh/authorized_keys` + +### rsync fails with "connection unexpectedly closed" +- Usually means SSH key auth failed +- Check the job log for the exact SSH error +- Try running the rsync command manually from the server with `-v` flag + +### Machine shows "unknown" status +- Status is updated when a job runs or when you test SSH +- It does not auto-poll; this is by design to avoid network load + +## API Reference + +All API endpoints require authentication (JWT cookie). + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/auth/login` | Login with username/password | +| POST | `/api/auth/logout` | Clear session | +| GET | `/api/auth/me` | Current user info | +| GET/POST | `/api/machines` | List/create machines | +| GET/PUT/DELETE | `/api/machines/{id}` | Get/update/delete machine | +| GET/POST | `/api/sync-pairs` | List/create sync pairs | +| GET/PUT/DELETE | `/api/sync-pairs/{id}` | Get/update/delete sync pair | +| POST | `/api/sync-pairs/{id}/run` | Trigger manual sync | +| GET | `/api/jobs` | List jobs | +| POST | `/api/jobs/{id}/cancel` | Cancel a running job | +| GET | `/api/jobs/{id}/log` | Stream job log | +| GET | `/api/jobs/stream` | SSE stream of all job events | +| GET | `/api/settings/pubkey` | Get server's SSH public key | + +## License + +MIT diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..537d86f --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/syncserver/internal/api" + "github.com/syncserver/internal/auth" + "github.com/syncserver/internal/config" + "github.com/syncserver/internal/db" + "github.com/syncserver/internal/scheduler" + "github.com/syncserver/internal/sshmanager" + "github.com/syncserver/internal/syncengine" +) + +var version = "dev" + +func main() { + cfgPath := flag.String("config", "", "Path to config.yaml") + dataDir := flag.String("data-dir", "", "Data directory") + addr := flag.String("addr", "", "HTTP listen address") + showVersion := flag.Bool("version", false, "Print version") + flag.Parse() + + if *showVersion { + fmt.Println(version) + return + } + + cfg, err := config.Load(*cfgPath, *dataDir, *addr) + if err != nil { + fmt.Fprintf(os.Stderr, "config error: %v\n", err) + os.Exit(1) + } + cfg.Version = version + + if err := cfg.EnsureDirs(); err != nil { + fmt.Fprintf(os.Stderr, "failed to create dirs: %v\n", err) + os.Exit(1) + } + + slogHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + slog.SetDefault(slog.New(slogHandler)) + + slog.Info("starting syncserver", + "version", version, + "data_dir", cfg.DataDir, + "addr", cfg.Addr, + ) + + database, err := db.Open(cfg.DBPath()) + if err != nil { + slog.Error("failed to open database", "error", err) + os.Exit(1) + } + defer database.Close() + + if err := database.RunMigrations(); err != nil { + slog.Error("failed to run migrations", "error", err) + os.Exit(1) + } + + if err := auth.SeedAdmin(database.DB, cfg.Auth.AdminUser, cfg.Auth.AdminPass); err != nil { + slog.Warn("admin seeding skipped or failed", "error", err) + } + + privKeyPath, _, pubKey, err := sshmanager.EnsureServerKey(cfg.SSHDir()) + if err != nil { + slog.Error("failed to ensure server SSH key", "error", err) + os.Exit(1) + } + slog.Info("server SSH key ready", "pub_key", pubKey) + _ = privKeyPath + + engine := syncengine.New(database, cfg) + sched := scheduler.New(database, engine, cfg) + + srv := &http.Server{ + Addr: config.NormalizeAddr(cfg.Addr), + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + apiServer := api.NewServer(cfg, database.DB, engine) + srv.Handler = apiServer + + go sched.Start() + go engine.Start() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + slog.Info("http server listening", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("http server error", "error", err) + os.Exit(1) + } + }() + + <-sigCh + slog.Info("shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sched.Stop() + engine.Stop() + + if err := srv.Shutdown(ctx); err != nil { + slog.Error("server shutdown error", "error", err) + } + slog.Info("bye") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..542231d --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/syncserver + +go 1.25.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-chi/chi/v5 v5.1.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sys v0.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.53.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c2a248f --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +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.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/internal/api/dto.go b/internal/api/dto.go new file mode 100644 index 0000000..8870e88 --- /dev/null +++ b/internal/api/dto.go @@ -0,0 +1,69 @@ +package api + +type MachineRequest struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + SSHUser string `json:"ssh_user"` + SSHKeyID *int64 `json:"ssh_key_id"` + MACAddress *string `json:"mac_address"` + WoLEnabled bool `json:"wol_enabled"` + BroadcastAddr *string `json:"broadcast_addr"` + WakeTimeoutSeconds int `json:"wake_timeout_seconds"` + WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"` +} + +type MachineResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + SSHUser string `json:"ssh_user"` + SSHKeyID *int64 `json:"ssh_key_id"` + MACAddress *string `json:"mac_address"` + WoLEnabled bool `json:"wol_enabled"` + BroadcastAddr *string `json:"broadcast_addr"` + WakeTimeoutSeconds int `json:"wake_timeout_seconds"` + WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"` + FingerprintConfirmed bool `json:"fingerprint_confirmed"` + Status string `json:"status"` +} + +type SyncPairRequest struct { + Name string `json:"name"` + SourceMachineID *int64 `json:"source_machine_id"` + SourcePath string `json:"source_path"` + DestMachineID *int64 `json:"dest_machine_id"` + DestPath string `json:"dest_path"` + Direction string `json:"direction"` + RsyncFlags string `json:"rsync_flags"` + ExcludePatterns string `json:"exclude_patterns"` + Enabled bool `json:"enabled"` +} + +type SyncPairResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + SourceMachineID *int64 `json:"source_machine_id"` + SourcePath string `json:"source_path"` + DestMachineID *int64 `json:"dest_machine_id"` + DestPath string `json:"dest_path"` + Direction string `json:"direction"` + RsyncFlags string `json:"rsync_flags"` + ExcludePatterns string `json:"exclude_patterns"` + Enabled bool `json:"enabled"` +} + +type JobResponse struct { + ID int64 `json:"id"` + SyncPairID int64 `json:"sync_pair_id"` + TriggerType string `json:"trigger_type"` + Status string `json:"status"` + StartedAt *string `json:"started_at"` + FinishedAt *string `json:"finished_at"` + LogFile *string `json:"log_file"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go new file mode 100644 index 0000000..b51512a --- /dev/null +++ b/internal/api/handlers_auth.go @@ -0,0 +1,107 @@ +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + + "github.com/syncserver/internal/auth" +) + +type AuthHandler struct { + db *sql.DB +} + +func NewAuthHandler(db *sql.DB) *AuthHandler { + return &AuthHandler{db: db} +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + User UserResponse `json:"user"` + ExpiresAt string `json:"expires_at"` +} + +type UserResponse struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + row := h.db.QueryRow( + "SELECT id, username, password_hash, role FROM users WHERE username = ?", + req.Username, + ) + var u struct { + ID int64 + Username string + PasswordHash string + Role string + } + if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role); err != nil { + http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) + return + } + + if !auth.VerifyPassword([]byte(u.PasswordHash), req.Password) { + http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) + return + } + + jwtMgr := auth.GetJWTManager() + if jwtMgr == nil { + http.Error(w, `{"error":"server misconfigured"}`, http.StatusInternalServerError) + return + } + + token, expiresAt, err := jwtMgr.Generate(u.ID, u.Username, u.Role) + if err != nil { + http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) + return + } + + auth.SetAuthCookie(w, token, expiresAt) + + resp := LoginResponse{ + User: UserResponse{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + }, + ExpiresAt: expiresAt.Format("2006-01-02T15:04:05Z07:00"), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + auth.ClearAuthCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + claims := auth.GetClaims(r.Context()) + if claims == nil { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + resp := UserResponse{ + ID: claims.UserID, + Username: claims.Username, + Role: claims.Role, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"user": resp}) +} diff --git a/internal/api/handlers_jobs.go b/internal/api/handlers_jobs.go new file mode 100644 index 0000000..46b286f --- /dev/null +++ b/internal/api/handlers_jobs.go @@ -0,0 +1,167 @@ +package api + +import ( + "database/sql" + "net/http" + "os" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/syncserver/internal/models" + "github.com/syncserver/internal/syncengine" +) + +type JobHandler struct { + db *sql.DB + engine *syncengine.Engine +} + +func NewJobHandler(db *sql.DB, engine *syncengine.Engine) *JobHandler { + return &JobHandler{db: db, engine: engine} +} + +func (h *JobHandler) List(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + if limit <= 0 || limit > 100 { + limit = 50 + } + + repo := models.NewJobRepository(h.db) + jobs, err := repo.GetAll(limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch jobs") + return + } + + out := make([]JobResponse, len(jobs)) + for i, j := range jobs { + out[i] = jobToResp(j) + } + writeJSON(w, out) +} + +func (h *JobHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + repo := models.NewJobRepository(h.db) + j, err := repo.GetByID(id) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "job not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch job") + return + } + writeJSON(w, jobToResp(*j)) +} + +func (h *JobHandler) Cancel(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + repo := models.NewJobRepository(h.db) + j, err := repo.GetByID(id) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "job not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch job") + return + } + + if j.Status != "queued" && j.Status != "waking_up" && j.Status != "running" { + writeError(w, http.StatusBadRequest, "job is not cancellable") + return + } + + if h.engine != nil { + h.engine.Cancel(id, j.SyncPairID) + } + + repo.UpdateStatus(id, "cancelled") + writeJSON(w, map[string]string{"status": "cancelled"}) +} + +func (h *JobHandler) TriggerRun(w http.ResponseWriter, r *http.Request) { + pairID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + if h.engine == nil { + writeError(w, http.StatusInternalServerError, "engine not available") + return + } + + jobID, err := h.engine.CreateJob(pairID, "manual") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create job") + return + } + + go func() { + h.engine.Run(r.Context(), jobID, pairID) + }() + + jobRepo := models.NewJobRepository(h.db) + j, _ := jobRepo.GetByID(jobID) + writeJSON(w, jobToResp(*j), http.StatusCreated) +} + +func (h *JobHandler) StreamLog(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming not supported") + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher.Flush() + + jobRepo := models.NewJobRepository(h.db) + j, err := jobRepo.GetByID(id) + if err == nil && j.LogFile != nil { + data, _ := os.ReadFile(*j.LogFile) + w.Write(data) + flusher.Flush() + } +} + +func jobToResp(j models.Job) JobResponse { + resp := JobResponse{ + ID: j.ID, + SyncPairID: j.SyncPairID, + TriggerType: j.TriggerType, + Status: j.Status, + LogFile: j.LogFile, + } + if j.StartedAt != nil { + s := j.StartedAt.Format(time.RFC3339) + resp.StartedAt = &s + } + if j.FinishedAt != nil { + s := j.FinishedAt.Format(time.RFC3339) + resp.FinishedAt = &s + } + return resp +} diff --git a/internal/api/handlers_machines.go b/internal/api/handlers_machines.go new file mode 100644 index 0000000..b6be959 --- /dev/null +++ b/internal/api/handlers_machines.go @@ -0,0 +1,226 @@ +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + "regexp" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/syncserver/internal/models" +) + +type MachineHandler struct { + db *sql.DB +} + +func NewMachineHandler(db *sql.DB) *MachineHandler { + return &MachineHandler{db: db} +} + +var macRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$`) + +func (h *MachineHandler) List(w http.ResponseWriter, r *http.Request) { + repo := models.NewMachineRepository(h.db) + ms, err := repo.GetAll() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch machines") + return + } + out := make([]MachineResponse, len(ms)) + for i, m := range ms { + out[i] = machineToResp(m) + } + writeJSON(w, out) +} + +func (h *MachineHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + repo := models.NewMachineRepository(h.db) + m, err := repo.GetByID(id) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "machine not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch machine") + return + } + writeJSON(w, machineToResp(*m)) +} + +func (h *MachineHandler) Create(w http.ResponseWriter, r *http.Request) { + var req MachineRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" || req.Host == "" { + writeError(w, http.StatusBadRequest, "name and host are required") + return + } + if req.Port <= 0 || req.Port > 65535 { + writeError(w, http.StatusBadRequest, "invalid port") + return + } + if req.SSHUser == "" { + req.SSHUser = "root" + } + if req.WakeTimeoutSeconds <= 0 { + req.WakeTimeoutSeconds = 120 + } + if req.WakeCheckIntervalSeconds <= 0 { + req.WakeCheckIntervalSeconds = 5 + } + if req.WoLEnabled && req.MACAddress != nil && !macRegex.MatchString(*req.MACAddress) { + writeError(w, http.StatusBadRequest, "invalid mac_address format (expected AA:BB:CC:DD:EE:FF)") + return + } + + m := &models.Machine{ + Name: req.Name, + Host: req.Host, + Port: req.Port, + SSHUser: req.SSHUser, + SSHKeyID: req.SSHKeyID, + MACAddress: req.MACAddress, + WoLEnabled: req.WoLEnabled, + BroadcastAddr: req.BroadcastAddr, + WakeTimeoutSeconds: req.WakeTimeoutSeconds, + WakeCheckIntervalSeconds: req.WakeCheckIntervalSeconds, + FingerprintConfirmed: false, + Status: "unknown", + } + + repo := models.NewMachineRepository(h.db) + id, err := repo.Create(m) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create machine") + return + } + m.ID = id + w.Header().Set("Location", "/api/machines/"+strconv.FormatInt(id, 10)) + writeJSON(w, machineToResp(*m), http.StatusCreated) +} + +func (h *MachineHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + var req MachineRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" || req.Host == "" { + writeError(w, http.StatusBadRequest, "name and host are required") + return + } + if req.Port <= 0 || req.Port > 65535 { + writeError(w, http.StatusBadRequest, "invalid port") + return + } + if req.SSHUser == "" { + req.SSHUser = "root" + } + if req.WoLEnabled && req.MACAddress != nil && !macRegex.MatchString(*req.MACAddress) { + writeError(w, http.StatusBadRequest, "invalid mac_address format") + return + } + + repo := models.NewMachineRepository(h.db) + existing, err := repo.GetByID(id) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "machine not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch machine") + return + } + + existing.Name = req.Name + existing.Host = req.Host + existing.Port = req.Port + existing.SSHUser = req.SSHUser + existing.SSHKeyID = req.SSHKeyID + existing.MACAddress = req.MACAddress + existing.WoLEnabled = req.WoLEnabled + existing.BroadcastAddr = req.BroadcastAddr + if req.WakeTimeoutSeconds > 0 { + existing.WakeTimeoutSeconds = req.WakeTimeoutSeconds + } + if req.WakeCheckIntervalSeconds > 0 { + existing.WakeCheckIntervalSeconds = req.WakeCheckIntervalSeconds + } + + if err := repo.Update(existing); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update machine") + return + } + writeJSON(w, machineToResp(*existing)) +} + +func (h *MachineHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + repo := models.NewMachineRepository(h.db) + if err := repo.Delete(id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete machine") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func machineToResp(m models.Machine) MachineResponse { + var status string + if m.LastSeenAt != nil { + status = m.Status + " (last seen " + m.LastSeenAt.Format(time.RFC3339) + ")" + } else { + status = m.Status + } + return MachineResponse{ + ID: m.ID, + Name: m.Name, + Host: m.Host, + Port: m.Port, + SSHUser: m.SSHUser, + SSHKeyID: m.SSHKeyID, + MACAddress: m.MACAddress, + WoLEnabled: m.WoLEnabled, + BroadcastAddr: m.BroadcastAddr, + WakeTimeoutSeconds: m.WakeTimeoutSeconds, + WakeCheckIntervalSeconds: m.WakeCheckIntervalSeconds, + FingerprintConfirmed: m.FingerprintConfirmed, + Status: status, + } +} + +func writeError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(ErrorResponse{Error: msg}) +} + +func writeJSON(w http.ResponseWriter, data interface{}, codes ...int) { + w.Header().Set("Content-Type", "application/json") + if len(codes) > 0 { + w.WriteHeader(codes[0]) + } + json.NewEncoder(w).Encode(data) +} diff --git a/internal/api/handlers_syncpairs.go b/internal/api/handlers_syncpairs.go new file mode 100644 index 0000000..aba3f74 --- /dev/null +++ b/internal/api/handlers_syncpairs.go @@ -0,0 +1,183 @@ +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + "regexp" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/syncserver/internal/models" +) + +type SyncPairHandler struct { + db *sql.DB +} + +func NewSyncPairHandler(db *sql.DB) *SyncPairHandler { + return &SyncPairHandler{db: db} +} + +var directionRegex = regexp.MustCompile(`^(push|pull|mirror)$`) + +func (h *SyncPairHandler) List(w http.ResponseWriter, r *http.Request) { + repo := models.NewSyncPairRepository(h.db) + pairs, err := repo.GetAll() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch sync pairs") + return + } + out := make([]SyncPairResponse, len(pairs)) + for i, p := range pairs { + out[i] = syncPairToResp(p) + } + writeJSON(w, out) +} + +func (h *SyncPairHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + repo := models.NewSyncPairRepository(h.db) + p, err := repo.GetByID(id) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "sync pair not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch sync pair") + return + } + writeJSON(w, syncPairToResp(*p)) +} + +func (h *SyncPairHandler) Create(w http.ResponseWriter, r *http.Request) { + var req SyncPairRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" || req.SourcePath == "" || req.DestPath == "" { + writeError(w, http.StatusBadRequest, "name, source_path and dest_path are required") + return + } + if req.Direction == "" { + req.Direction = "push" + } + if !directionRegex.MatchString(req.Direction) { + writeError(w, http.StatusBadRequest, "direction must be push, pull, or mirror") + return + } + if req.RsyncFlags == "" { + req.RsyncFlags = "-aP" + } + if !req.Enabled { + req.Enabled = true + } + + sp := &models.SyncPair{ + Name: req.Name, + SourceMachineID: req.SourceMachineID, + SourcePath: req.SourcePath, + DestMachineID: req.DestMachineID, + DestPath: req.DestPath, + Direction: req.Direction, + RsyncFlags: req.RsyncFlags, + ExcludePatterns: req.ExcludePatterns, + Enabled: req.Enabled, + } + + repo := models.NewSyncPairRepository(h.db) + id, err := repo.Create(sp) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create sync pair") + return + } + sp.ID = id + w.Header().Set("Location", "/api/sync-pairs/"+strconv.FormatInt(id, 10)) + writeJSON(w, syncPairToResp(*sp), http.StatusCreated) +} + +func (h *SyncPairHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + var req SyncPairRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Name == "" || req.SourcePath == "" || req.DestPath == "" { + writeError(w, http.StatusBadRequest, "name, source_path and dest_path are required") + return + } + if !directionRegex.MatchString(req.Direction) { + writeError(w, http.StatusBadRequest, "direction must be push, pull, or mirror") + return + } + + repo := models.NewSyncPairRepository(h.db) + existing, err := repo.GetByID(id) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "sync pair not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch sync pair") + return + } + + existing.Name = req.Name + existing.SourceMachineID = req.SourceMachineID + existing.SourcePath = req.SourcePath + existing.DestMachineID = req.DestMachineID + existing.DestPath = req.DestPath + existing.Direction = req.Direction + existing.RsyncFlags = req.RsyncFlags + existing.ExcludePatterns = req.ExcludePatterns + existing.Enabled = req.Enabled + + if err := repo.Update(existing); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update sync pair") + return + } + writeJSON(w, syncPairToResp(*existing)) +} + +func (h *SyncPairHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + repo := models.NewSyncPairRepository(h.db) + if err := repo.Delete(id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete sync pair") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func syncPairToResp(p models.SyncPair) SyncPairResponse { + return SyncPairResponse{ + ID: p.ID, + Name: p.Name, + SourceMachineID: p.SourceMachineID, + SourcePath: p.SourcePath, + DestMachineID: p.DestMachineID, + DestPath: p.DestPath, + Direction: p.Direction, + RsyncFlags: p.RsyncFlags, + ExcludePatterns: p.ExcludePatterns, + Enabled: p.Enabled, + } +} diff --git a/internal/api/handlers_ws.go b/internal/api/handlers_ws.go new file mode 100644 index 0000000..bcbdd7c --- /dev/null +++ b/internal/api/handlers_ws.go @@ -0,0 +1,64 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/syncserver/internal/syncengine" +) + +type SSEHandler struct { + engine *syncengine.Engine +} + +func NewSSEHandler(engine *syncengine.Engine) *SSEHandler { + return &SSEHandler{engine: engine} +} + +func (h *SSEHandler) Stream(w http.ResponseWriter, r *http.Request) { + jobIDStr := r.URL.Query().Get("job_id") + var filterJobID int64 + if jobIDStr != "" { + filterJobID, _ = strconv.ParseInt(jobIDStr, 10, 64) + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher.Flush() + + if h.engine == nil { + return + } + + events := h.engine.Events() + for { + select { + case evt := <-events: + if filterJobID != 0 && evt.JobID != filterJobID { + continue + } + data, _ := json.Marshal(evt) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Type, data) + flusher.Flush() + + case <-r.Context().Done(): + return + + case <-time.After(30 * time.Second): + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..16bfb05 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,102 @@ +package api + +import ( + "database/sql" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/syncserver/internal/auth" + "github.com/syncserver/internal/config" + "github.com/syncserver/internal/sshmanager" + "github.com/syncserver/internal/syncengine" + "github.com/syncserver/internal/webui" +) + +type Server struct { + router *chi.Mux + cfg *config.Config + engine *syncengine.Engine +} + +func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Server { + auth.InitJWTManager(cfg.Auth.JWTSecret, cfg.Auth.JWTExpiryH) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + s := &Server{router: r, cfg: cfg, engine: engine} + + authHandler := NewAuthHandler(db) + machineHandler := NewMachineHandler(db) + syncPairHandler := NewSyncPairHandler(db) + jobHandler := NewJobHandler(db, engine) + sseHandler := NewSSEHandler(engine) + + r.Route("/api", func(r chi.Router) { + r.Route("/auth", func(r chi.Router) { + r.Post("/login", authHandler.Login) + r.Post("/logout", authHandler.Logout) + r.With(auth.RequireAuth).Get("/me", authHandler.Me) + }) + + r.With(auth.RequireAuth).Route("/machines", func(r chi.Router) { + r.Get("/", machineHandler.List) + r.Post("/", machineHandler.Create) + r.Get("/{id}", machineHandler.Get) + r.Put("/{id}", machineHandler.Update) + r.Delete("/{id}", machineHandler.Delete) + }) + + r.With(auth.RequireAuth).Route("/sync-pairs", func(r chi.Router) { + r.Get("/", syncPairHandler.List) + r.Post("/", syncPairHandler.Create) + r.Get("/{id}", syncPairHandler.Get) + r.Put("/{id}", syncPairHandler.Update) + r.Delete("/{id}", syncPairHandler.Delete) + r.Post("/{id}/run", jobHandler.TriggerRun) + }) + + r.With(auth.RequireAuth).Route("/jobs", func(r chi.Router) { + r.Get("/", jobHandler.List) + r.Get("/{id}", jobHandler.Get) + r.Post("/{id}/cancel", jobHandler.Cancel) + r.Get("/{id}/log", jobHandler.StreamLog) + }) + + r.With(auth.RequireAuth).Get("/jobs/stream", sseHandler.Stream) + + r.With(auth.RequireAuth).Get("/settings/pubkey", func(w http.ResponseWriter, r *http.Request) { + _, _, pubKey, _ := sshmanager.EnsureServerKey(cfg.SSHDir()) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(pubKey)) + }) + }) + + r.Get("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + if _, ok := webui.DistFS.Open("dist" + r.URL.Path); ok == nil { + http.FileServer(http.FS(webui.DistFS)).ServeHTTP(w, r) + return + } + data, err := webui.DistFS.ReadFile("dist/index.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(data) + }) + + return s +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.router.ServeHTTP(w, r) +} diff --git a/internal/auth/cookies.go b/internal/auth/cookies.go new file mode 100644 index 0000000..54b4dd0 --- /dev/null +++ b/internal/auth/cookies.go @@ -0,0 +1,40 @@ +package auth + +import ( + "net/http" + "time" +) + +const CookieName = "ss_token" + +func SetAuthCookie(w http.ResponseWriter, token string, expiresAt time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + Expires: expiresAt, + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteStrictMode, + }) +} + +func ClearAuthCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteStrictMode, + }) +} + +func GetTokenFromRequest(r *http.Request) string { + cookie, err := r.Cookie(CookieName) + if err != nil { + return "" + } + return cookie.Value +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..e72fcb7 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,69 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token expired") +) + +type Claims struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type JWTManager struct { + secret []byte + expiryH int +} + +func NewJWTManager(secret string, expiryH int) *JWTManager { + return &JWTManager{ + secret: []byte(secret), + expiryH: expiryH, + } +} + +func (m *JWTManager) Generate(userID int64, username, role string) (string, time.Time, error) { + expiresAt := time.Now().Add(time.Duration(m.expiryH) * time.Hour) + claims := &Claims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(m.secret) + return signed, expiresAt, err +} + +func (m *JWTManager) Validate(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return m.secret, nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + return claims, nil +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..1869293 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "net/http" +) + +type ctxKey string + +const ClaimsCtxKey ctxKey = "claims" + +type contextKey struct{} + +func RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := GetTokenFromRequest(r) + if token == "" { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + claims, err := GlobalJWTManager.Validate(token) + if err != nil { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + ctx := r.Context() + ctx = context.WithValue(ctx, ClaimsCtxKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r.Context()) + if claims == nil || claims.Role != "admin" { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func GetClaims(ctx context.Context) *Claims { + v := ctx.Value(ClaimsCtxKey) + if v == nil { + return nil + } + return v.(*Claims) +} + +var GlobalJWTManager *JWTManager + +func InitJWTManager(secret string, expiryH int) { + GlobalJWTManager = NewJWTManager(secret, expiryH) +} + +func GetJWTManager() *JWTManager { + return GlobalJWTManager +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..52e1d16 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,16 @@ +package auth + +import ( + "golang.org/x/crypto/bcrypt" +) + +var bcryptCost = bcrypt.DefaultCost + +func HashPassword(plain string) ([]byte, error) { + return bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) +} + +func VerifyPassword(hash []byte, plain string) bool { + err := bcrypt.CompareHashAndPassword(hash, []byte(plain)) + return err == nil +} diff --git a/internal/auth/seed.go b/internal/auth/seed.go new file mode 100644 index 0000000..351cea7 --- /dev/null +++ b/internal/auth/seed.go @@ -0,0 +1,37 @@ +package auth + +import ( + "database/sql" + "log/slog" +) + +func SeedAdmin(db *sql.DB, username, password string) error { + if username == "" || password == "" { + return nil + } + + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)", username).Scan(&exists) + if err != nil { + return err + } + if exists { + return nil + } + + hash, err := HashPassword(password) + if err != nil { + return err + } + + _, err = db.Exec( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + username, string(hash), "admin", + ) + if err != nil { + return err + } + + slog.Info("admin user created", "username", username) + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..092c548 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,149 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Version string `yaml:"-" json:"-"` + DataDir string `yaml:"data_dir" env:"SYNCSERVER_DATA_DIR" default:"./data"` + ConfigDir string `yaml:"config_dir" env:"SYNCSERVER_CONFIG_DIR" default:"./etc/syncserver"` + Addr string `yaml:"addr" env:"SYNCSERVER_ADDR" default:":8080"` + + Auth AuthConfig `yaml:"auth"` + + Scheduler SchedulerConfig `yaml:"scheduler"` +} + +type AuthConfig struct { + JWTSecret string `yaml:"jwt_secret" env:"SYNCSERVER_JWT_SECRET"` + JWTExpiryH int `yaml:"jwt_expiry_hours" env:"SYNCSERVER_JWT_EXPIRY_HOURS" default:"24"` + AdminUser string `yaml:"-" env:"SYNCSERVER_ADMIN_USER"` + AdminPass string `yaml:"-" env:"SYNCSERVER_ADMIN_PASSWORD"` +} + +type SchedulerConfig struct { + Timezone string `yaml:"timezone" env:"SYNCSERVER_SCHEDULER_TZ" default:"UTC"` +} + +var globalCfg *Config + +func Load(configPath, dataDir, addr string) (*Config, error) { + cfg := &Config{ + DataDir: dataDir, + ConfigDir: "./etc/syncserver", + Addr: addr, + Auth: AuthConfig{ + JWTExpiryH: 24, + }, + Scheduler: SchedulerConfig{ + Timezone: "UTC", + }, + } + + if configPath != "" { + data, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("reading config: %w", err) + } + if err == nil { + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + } + } + + prefix := "SYNCSERVER_" + envs := []struct { + key *string + envName string + required bool + }{ + {&cfg.Auth.JWTSecret, prefix + "JWT_SECRET", false}, + {&cfg.Auth.AdminUser, prefix + "ADMIN_USER", false}, + {&cfg.Auth.AdminPass, prefix + "ADMIN_PASSWORD", false}, + {&cfg.DataDir, prefix + "DATA_DIR", false}, + {&cfg.Addr, prefix + "ADDR", false}, + {&cfg.Scheduler.Timezone, prefix + "SCHEDULER_TZ", false}, + } + + for _, e := range envs { + if v := os.Getenv(e.envName); v != "" { + *e.key = v + } + } + + if cfg.Auth.JWTSecret == "" { + b := make([]byte, 32) + f, err := os.Open("/dev/urandom") + if err == nil { + defer f.Close() + n, _ := f.Read(b) + if n == 32 { + cfg.Auth.JWTSecret = fmt.Sprintf("%x", b) + } + } + if cfg.Auth.JWTSecret == "" { + cfg.Auth.JWTSecret = "insecure-dev-secret-change-in-production" + } + } + + if dataDir := os.Getenv("SYNCSERVER_DATA_DIR"); dataDir != "" { + cfg.DataDir = dataDir + } + if addr := os.Getenv("SYNCSERVER_ADDR"); addr != "" { + cfg.Addr = addr + } + + absDataDir, err := filepath.Abs(cfg.DataDir) + if err != nil { + return nil, err + } + cfg.DataDir = absDataDir + + globalCfg = cfg + return cfg, nil +} + +func Get() *Config { + return globalCfg +} + +func (c *Config) DBPath() string { + return filepath.Join(c.DataDir, "app.db") +} + +func (c *Config) SSHDir() string { + return filepath.Join(c.DataDir, "ssh") +} + +func (c *Config) LogsDir() string { + return filepath.Join(c.DataDir, "logs") +} + +func (c *Config) EnsureDirs() error { + dirs := []string{c.DataDir, c.SSHDir(), c.LogsDir()} + for _, d := range dirs { + if err := os.MkdirAll(d, 0700); err != nil { + return fmt.Errorf("creating dir %s: %w", d, err) + } + } + return nil +} + +func (c *Config) LogPath() string { + return filepath.Join(c.LogsDir(), "app.log") +} + +func NormalizeAddr(addr string) string { + addr = strings.TrimSpace(addr) + if !strings.Contains(addr, ":") { + addr = ":" + addr + } + return addr +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..014ea59 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,102 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + _ "modernc.org/sqlite" +) + +type DB struct { + *sql.DB +} + +func (d *DB) SQLDB() *sql.DB { + return d.DB +} + +func Open(dbPath string) (*DB, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_foreign_keys=ON&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("opening db: %w", err) + } + + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { + db.Close() + return nil, fmt.Errorf("enabling foreign_keys: %w", err) + } + + return &DB{db}, nil +} + +func (db *DB) Close() error { + return db.DB.Close() +} + +func (db *DB) RunMigrations() error { + return db.runMigrationsInternal(migrationsFS, "migrations") +} + +func (db *DB) runMigrationsInternal(mfs embedFS, migrationsRoot string) error { + entries, err := mfs.ReadDir(migrationsRoot) + if err != nil { + return fmt.Errorf("reading migrations dir: %w", err) + } + + var names []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + return fmt.Errorf("creating schema_migrations table: %w", err) + } + + for _, name := range names { + var applied bool + row := db.QueryRow("SELECT 1 FROM schema_migrations WHERE version = ?", name) + if err := row.Scan(&applied); err == nil { + applied = true + } + + if applied { + continue + } + + data, err := mfs.ReadFile(filepath.Join(migrationsRoot, name)) + if err != nil { + return fmt.Errorf("reading migration %s: %w", name, err) + } + + if _, err := db.Exec(string(data)); err != nil { + return fmt.Errorf("applying migration %s: %w", name, err) + } + + if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", name); err != nil { + return fmt.Errorf("recording migration %s: %w", name, err) + } + } + + return nil +} + +type embedFS interface { + ReadDir(name string) ([]os.DirEntry, error) + ReadFile(name string) ([]byte, error) +} diff --git a/internal/db/migrations.go b/internal/db/migrations.go new file mode 100644 index 0000000..42aaf70 --- /dev/null +++ b/internal/db/migrations.go @@ -0,0 +1,7 @@ +package db + +import "embed" + +// migrationsFS is the embedded filesystem containing SQL migration files. +//go:embed migrations +var migrationsFS embed.FS diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..91087ca --- /dev/null +++ b/internal/db/migrations/0001_init.sql @@ -0,0 +1,86 @@ +-- 0001_init.sql + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS ssh_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + private_key_path TEXT NOT NULL, + public_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS machines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + ssh_user TEXT NOT NULL DEFAULT 'root', + ssh_key_id INTEGER REFERENCES ssh_keys(id), + mac_address TEXT, + wol_enabled INTEGER NOT NULL DEFAULT 0, + broadcast_addr TEXT, + wake_timeout_seconds INTEGER NOT NULL DEFAULT 120, + wake_check_interval_seconds INTEGER NOT NULL DEFAULT 5, + fingerprint_confirmed INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'unknown', + last_seen_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sync_pairs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + source_machine_id INTEGER REFERENCES machines(id), + source_path TEXT NOT NULL, + dest_machine_id INTEGER REFERENCES machines(id), + dest_path TEXT NOT NULL, + direction TEXT NOT NULL DEFAULT 'push', + rsync_flags TEXT NOT NULL DEFAULT '-aP', + exclude_patterns TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_pair_id INTEGER NOT NULL REFERENCES sync_pairs(id) ON DELETE CASCADE, + cron_expr TEXT NOT NULL, + next_run_at DATETIME, + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_pair_id INTEGER NOT NULL REFERENCES sync_pairs(id), + trigger_type TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'queued', + started_at DATETIME, + finished_at DATETIME, + log_file TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS job_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + stream TEXT NOT NULL, + content TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at DATETIME NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/internal/models/job.go b/internal/models/job.go new file mode 100644 index 0000000..8b6f7b1 --- /dev/null +++ b/internal/models/job.go @@ -0,0 +1,145 @@ +package models + +import ( + "database/sql" + "time" +) + +type Job struct { + ID int64 `db:"id" json:"id"` + SyncPairID int64 `db:"sync_pair_id" json:"sync_pair_id"` + TriggerType string `db:"trigger_type" json:"trigger_type"` + Status string `db:"status" json:"status"` + StartedAt *time.Time `db:"started_at" json:"started_at"` + FinishedAt *time.Time `db:"finished_at" json:"finished_at"` + LogFile *string `db:"log_file" json:"log_file"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type JobRepository struct { + db *sql.DB +} + +func NewJobRepository(db *sql.DB) *JobRepository { + return &JobRepository{db: db} +} + +func (r *JobRepository) Create(syncPairID int64, triggerType, status string) (int64, error) { + res, err := r.db.Exec(` + INSERT INTO jobs (sync_pair_id, trigger_type, status) VALUES (?, ?, ?)`, + syncPairID, triggerType, status, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (r *JobRepository) GetByID(id int64) (*Job, error) { + var j Job + var started, finished sql.NullTime + var logFile sql.NullString + err := r.db.QueryRow(` + SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at, + log_file, created_at FROM jobs WHERE id = ?`, id).Scan( + &j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, &started, &finished, + &logFile, &j.CreatedAt) + if err != nil { + return nil, err + } + if started.Valid { + j.StartedAt = &started.Time + } + if finished.Valid { + j.FinishedAt = &finished.Time + } + if logFile.Valid { + j.LogFile = &logFile.String + } + return &j, nil +} + +func (r *JobRepository) GetAll(limit, offset int) ([]Job, error) { + rows, err := r.db.Query(` + SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at, + log_file, created_at FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?`, + limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var jobs []Job + for rows.Next() { + var j Job + var started, finished sql.NullTime + var logFile sql.NullString + if err := rows.Scan(&j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, + &started, &finished, &logFile, &j.CreatedAt); err != nil { + return nil, err + } + if started.Valid { + j.StartedAt = &started.Time + } + if finished.Valid { + j.FinishedAt = &finished.Time + } + if logFile.Valid { + j.LogFile = &logFile.String + } + jobs = append(jobs, j) + } + return jobs, rows.Err() +} + +func (r *JobRepository) UpdateStatus(id int64, status string) error { + var query string + var args []interface{} + switch status { + case "running", "waking_up": + query = "UPDATE jobs SET status = ?, started_at = COALESCE(started_at, CURRENT_TIMESTAMP) WHERE id = ?" + args = []interface{}{status, id} + case "success", "failed", "cancelled": + query = "UPDATE jobs SET status = ?, finished_at = CURRENT_TIMESTAMP WHERE id = ?" + args = []interface{}{status, id} + default: + query = "UPDATE jobs SET status = ? WHERE id = ?" + args = []interface{}{status, id} + } + _, err := r.db.Exec(query, args...) + return err +} + +func (r *JobRepository) SetLogFile(id int64, path string) error { + _, err := r.db.Exec("UPDATE jobs SET log_file = ? WHERE id = ?", path, id) + return err +} + +func (r *JobRepository) GetRunningBySyncPair(syncPairID int64) (*Job, error) { + var j Job + var started sql.NullTime + var logFile sql.NullString + err := r.db.QueryRow(` + SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at, + log_file, created_at FROM jobs + WHERE sync_pair_id = ? AND status IN ('queued','waking_up','running') + ORDER BY created_at DESC LIMIT 1`, syncPairID).Scan( + &j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, &started, + &j.FinishedAt, &logFile, &j.CreatedAt) + if err != nil { + return nil, err + } + if started.Valid { + j.StartedAt = &started.Time + } + if logFile.Valid { + j.LogFile = &logFile.String + } + return &j, nil +} + +func (r *JobRepository) Count() (int64, error) { + var n int64 + err := r.db.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n) + return n, err +} diff --git a/internal/models/machine.go b/internal/models/machine.go new file mode 100644 index 0000000..bc8a3e9 --- /dev/null +++ b/internal/models/machine.go @@ -0,0 +1,161 @@ +package models + +import ( + "database/sql" + "time" +) + +type Machine struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Host string `db:"host" json:"host"` + Port int `db:"port" json:"port"` + SSHUser string `db:"ssh_user" json:"ssh_user"` + SSHKeyID *int64 `db:"ssh_key_id" json:"ssh_key_id"` + MACAddress *string `db:"mac_address" json:"mac_address"` + WoLEnabled bool `db:"wol_enabled" json:"wol_enabled"` + BroadcastAddr *string `db:"broadcast_addr" json:"broadcast_addr"` + WakeTimeoutSeconds int `db:"wake_timeout_seconds" json:"wake_timeout_seconds"` + WakeCheckIntervalSeconds int `db:"wake_check_interval_seconds" json:"wake_check_interval_seconds"` + FingerprintConfirmed bool `db:"fingerprint_confirmed" json:"fingerprint_confirmed"` + Status string `db:"status" json:"status"` + LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type MachineRepository struct { + db *sql.DB +} + +func NewMachineRepository(db *sql.DB) *MachineRepository { + return &MachineRepository{db: db} +} + +func (r *MachineRepository) Create(m *Machine) (int64, error) { + res, err := r.db.Exec(` + INSERT INTO machines (name, host, port, ssh_user, ssh_key_id, mac_address, + wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, + fingerprint_confirmed, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress, + boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds, + m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), m.Status, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (r *MachineRepository) GetAll() ([]Machine, error) { + rows, err := r.db.Query(` + SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address, + wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, + fingerprint_confirmed, status, last_seen_at, created_at + FROM machines ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var ms []Machine + for rows.Next() { + var m Machine + var mac, bcast sql.NullString + var keyID sql.NullInt64 + var lastSeen sql.NullTime + err := rows.Scan(&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID, + &mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds, + &m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed, + &m.Status, &lastSeen, &m.CreatedAt) + if err != nil { + return nil, err + } + if keyID.Valid { + v := keyID.Int64 + m.SSHKeyID = &v + } + if mac.Valid { + m.MACAddress = &mac.String + } + if bcast.Valid { + m.BroadcastAddr = &bcast.String + } + if lastSeen.Valid { + m.LastSeenAt = &lastSeen.Time + } + ms = append(ms, m) + } + return ms, rows.Err() +} + +func (r *MachineRepository) GetByID(id int64) (*Machine, error) { + var m Machine + var mac, bcast sql.NullString + var keyID sql.NullInt64 + var lastSeen sql.NullTime + err := r.db.QueryRow(` + SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address, + wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, + fingerprint_confirmed, status, last_seen_at, created_at + FROM machines WHERE id = ?`, id).Scan( + &m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID, + &mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds, + &m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed, + &m.Status, &lastSeen, &m.CreatedAt) + if err != nil { + return nil, err + } + if keyID.Valid { + v := keyID.Int64 + m.SSHKeyID = &v + } + if mac.Valid { + m.MACAddress = &mac.String + } + if bcast.Valid { + m.BroadcastAddr = &bcast.String + } + if lastSeen.Valid { + m.LastSeenAt = &lastSeen.Time + } + return &m, nil +} + +func (r *MachineRepository) Update(m *Machine) error { + _, err := r.db.Exec(` + UPDATE machines SET name=?, host=?, port=?, ssh_user=?, ssh_key_id=?, + mac_address=?, wol_enabled=?, broadcast_addr=?, wake_timeout_seconds=?, + wake_check_interval_seconds=?, fingerprint_confirmed=?, status=?, last_seen_at=? + WHERE id=?`, + m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress, + boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds, + m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), + m.Status, m.LastSeenAt, m.ID, + ) + return err +} + +func (r *MachineRepository) Delete(id int64) error { + _, err := r.db.Exec("DELETE FROM machines WHERE id = ?", id) + return err +} + +func (r *MachineRepository) UpdateStatus(id int64, status string) error { + _, err := r.db.Exec( + "UPDATE machines SET status = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?", + status, id, + ) + return err +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func intToBool(i int) bool { + return i != 0 +} diff --git a/internal/models/schedule.go b/internal/models/schedule.go new file mode 100644 index 0000000..5444bb4 --- /dev/null +++ b/internal/models/schedule.go @@ -0,0 +1,124 @@ +package models + +import ( + "database/sql" + "time" +) + +type Schedule struct { + ID int64 `db:"id" json:"id"` + SyncPairID int64 `db:"sync_pair_id" json:"sync_pair_id"` + CronExpr string `db:"cron_expr" json:"cron_expr"` + NextRunAt *time.Time `db:"next_run_at" json:"next_run_at"` + Enabled bool `db:"enabled" json:"enabled"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type ScheduleRepository struct { + db *sql.DB +} + +func NewScheduleRepository(db *sql.DB) *ScheduleRepository { + return &ScheduleRepository{db: db} +} + +func (r *ScheduleRepository) Create(s *Schedule) (int64, error) { + res, err := r.db.Exec(` + INSERT INTO schedules (sync_pair_id, cron_expr, next_run_at, enabled) + VALUES (?, ?, ?, ?)`, + s.SyncPairID, s.CronExpr, s.NextRunAt, boolToInt(s.Enabled), + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (r *ScheduleRepository) GetAll() ([]Schedule, error) { + rows, err := r.db.Query(` + SELECT id, sync_pair_id, cron_expr, next_run_at, enabled, created_at + FROM schedules ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var schedules []Schedule + for rows.Next() { + var s Schedule + var nextRun sql.NullTime + if err := rows.Scan(&s.ID, &s.SyncPairID, &s.CronExpr, &nextRun, + &s.Enabled, &s.CreatedAt); err != nil { + return nil, err + } + if nextRun.Valid { + s.NextRunAt = &nextRun.Time + } + s.Enabled = intToBool(intToInt(s.Enabled)) + schedules = append(schedules, s) + } + return schedules, rows.Err() +} + +func (r *ScheduleRepository) GetByID(id int64) (*Schedule, error) { + var s Schedule + var nextRun sql.NullTime + err := r.db.QueryRow(` + SELECT id, sync_pair_id, cron_expr, next_run_at, enabled, created_at + FROM schedules WHERE id = ?`, id).Scan( + &s.ID, &s.SyncPairID, &s.CronExpr, &nextRun, &s.Enabled, &s.CreatedAt) + if err != nil { + return nil, err + } + if nextRun.Valid { + s.NextRunAt = &nextRun.Time + } + s.Enabled = intToBool(intToInt(s.Enabled)) + return &s, nil +} + +func (r *ScheduleRepository) Update(s *Schedule) error { + _, err := r.db.Exec(` + UPDATE schedules SET sync_pair_id=?, cron_expr=?, next_run_at=?, enabled=? + WHERE id=?`, + s.SyncPairID, s.CronExpr, s.NextRunAt, boolToInt(s.Enabled), s.ID, + ) + return err +} + +func (r *ScheduleRepository) Delete(id int64) error { + _, err := r.db.Exec("DELETE FROM schedules WHERE id = ?", id) + return err +} + +func (r *ScheduleRepository) GetEnabledDue(before time.Time) ([]Schedule, error) { + rows, err := r.db.Query(` + SELECT id, sync_pair_id, cron_expr, next_run_at, enabled, created_at + FROM schedules WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? + ORDER BY next_run_at`, before) + if err != nil { + return nil, err + } + defer rows.Close() + + var schedules []Schedule + for rows.Next() { + var s Schedule + var nextRun sql.NullTime + if err := rows.Scan(&s.ID, &s.SyncPairID, &s.CronExpr, &nextRun, + &s.Enabled, &s.CreatedAt); err != nil { + return nil, err + } + if nextRun.Valid { + s.NextRunAt = &nextRun.Time + } + s.Enabled = true + schedules = append(schedules, s) + } + return schedules, rows.Err() +} + +func (r *ScheduleRepository) UpdateNextRun(id int64, nextRun time.Time) error { + _, err := r.db.Exec("UPDATE schedules SET next_run_at = ? WHERE id = ?", nextRun, id) + return err +} diff --git a/internal/models/sshkey.go b/internal/models/sshkey.go new file mode 100644 index 0000000..b4a33bf --- /dev/null +++ b/internal/models/sshkey.go @@ -0,0 +1,80 @@ +package models + +import ( + "database/sql" + "time" +) + +type SSHKey struct { + ID int64 `db:"id" json:"id"` + Label string `db:"label" json:"label"` + PrivateKeyPath string `db:"private_key_path" json:"-"` + PublicKey string `db:"public_key" json:"public_key"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type SSHKeyRepository struct { + db *sql.DB +} + +func NewSSHKeyRepository(db *sql.DB) *SSHKeyRepository { + return &SSHKeyRepository{db: db} +} + +func (r *SSHKeyRepository) Create(label, privPath, pubKey string) (int64, error) { + res, err := r.db.Exec( + "INSERT INTO ssh_keys (label, private_key_path, public_key) VALUES (?, ?, ?)", + label, privPath, pubKey, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (r *SSHKeyRepository) GetByID(id int64) (*SSHKey, error) { + var k SSHKey + err := r.db.QueryRow( + "SELECT id, label, private_key_path, public_key, created_at FROM ssh_keys WHERE id = ?", + id, + ).Scan(&k.ID, &k.Label, &k.PrivateKeyPath, &k.PublicKey, &k.CreatedAt) + if err != nil { + return nil, err + } + return &k, nil +} + +func (r *SSHKeyRepository) GetAll() ([]SSHKey, error) { + rows, err := r.db.Query( + "SELECT id, label, private_key_path, public_key, created_at FROM ssh_keys ORDER BY label") + if err != nil { + return nil, err + } + defer rows.Close() + + var keys []SSHKey + for rows.Next() { + var k SSHKey + if err := rows.Scan(&k.ID, &k.Label, &k.PrivateKeyPath, &k.PublicKey, &k.CreatedAt); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} + +func (r *SSHKeyRepository) Delete(id int64) error { + _, err := r.db.Exec("DELETE FROM ssh_keys WHERE id = ?", id) + return err +} + +func (r *SSHKeyRepository) GetServerKey() (*SSHKey, error) { + var k SSHKey + err := r.db.QueryRow( + "SELECT id, label, private_key_path, public_key, created_at FROM ssh_keys WHERE label = 'server' LIMIT 1", + ).Scan(&k.ID, &k.Label, &k.PrivateKeyPath, &k.PublicKey, &k.CreatedAt) + if err != nil { + return nil, err + } + return &k, nil +} diff --git a/internal/models/syncpair.go b/internal/models/syncpair.go new file mode 100644 index 0000000..6482357 --- /dev/null +++ b/internal/models/syncpair.go @@ -0,0 +1,181 @@ +package models + +import ( + "database/sql" + "strings" + "time" +) + +type SyncPair struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + SourceMachineID *int64 `db:"source_machine_id" json:"source_machine_id"` + SourcePath string `db:"source_path" json:"source_path"` + DestMachineID *int64 `db:"dest_machine_id" json:"dest_machine_id"` + DestPath string `db:"dest_path" json:"dest_path"` + Direction string `db:"direction" json:"direction"` + RsyncFlags string `db:"rsync_flags" json:"rsync_flags"` + ExcludePatterns string `db:"exclude_patterns" json:"exclude_patterns"` + Enabled bool `db:"enabled" json:"enabled"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type SyncPairRepository struct { + db *sql.DB +} + +func NewSyncPairRepository(db *sql.DB) *SyncPairRepository { + return &SyncPairRepository{db: db} +} + +func (r *SyncPairRepository) Create(sp *SyncPair) (int64, error) { + res, err := r.db.Exec(` + INSERT INTO sync_pairs (name, source_machine_id, source_path, dest_machine_id, + dest_path, direction, rsync_flags, exclude_patterns, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sp.Name, sp.SourceMachineID, sp.SourcePath, sp.DestMachineID, + sp.DestPath, sp.Direction, sp.RsyncFlags, sp.ExcludePatterns, boolToInt(sp.Enabled), + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (r *SyncPairRepository) GetAll() ([]SyncPair, error) { + rows, err := r.db.Query(` + SELECT id, name, source_machine_id, source_path, dest_machine_id, dest_path, + direction, rsync_flags, exclude_patterns, enabled, created_at + FROM sync_pairs ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var pairs []SyncPair + for rows.Next() { + var p SyncPair + var srcID, dstID sql.NullInt64 + if err := rows.Scan(&p.ID, &p.Name, &srcID, &p.SourcePath, &dstID, + &p.DestPath, &p.Direction, &p.RsyncFlags, &p.ExcludePatterns, + &p.Enabled, &p.CreatedAt); err != nil { + return nil, err + } + if srcID.Valid { + v := srcID.Int64 + p.SourceMachineID = &v + } + if dstID.Valid { + v := dstID.Int64 + p.DestMachineID = &v + } + p.Enabled = intToBool(intToInt(p.Enabled)) + pairs = append(pairs, p) + } + return pairs, rows.Err() +} + +func intToInt(v interface{}) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case bool: + if x { + return 1 + } + return 0 + default: + return 0 + } +} + +func (r *SyncPairRepository) GetByID(id int64) (*SyncPair, error) { + var p SyncPair + var srcID, dstID sql.NullInt64 + err := r.db.QueryRow(` + SELECT id, name, source_machine_id, source_path, dest_machine_id, dest_path, + direction, rsync_flags, exclude_patterns, enabled, created_at + FROM sync_pairs WHERE id = ?`, id).Scan( + &p.ID, &p.Name, &srcID, &p.SourcePath, &dstID, + &p.DestPath, &p.Direction, &p.RsyncFlags, &p.ExcludePatterns, + &p.Enabled, &p.CreatedAt) + if err != nil { + return nil, err + } + if srcID.Valid { + v := srcID.Int64 + p.SourceMachineID = &v + } + if dstID.Valid { + v := dstID.Int64 + p.DestMachineID = &v + } + p.Enabled = intToBool(intToInt(p.Enabled)) + return &p, nil +} + +func (r *SyncPairRepository) Update(sp *SyncPair) error { + _, err := r.db.Exec(` + UPDATE sync_pairs SET name=?, source_machine_id=?, source_path=?, dest_machine_id=?, + dest_path=?, direction=?, rsync_flags=?, exclude_patterns=?, enabled=? + WHERE id=?`, + sp.Name, sp.SourceMachineID, sp.SourcePath, sp.DestMachineID, + sp.DestPath, sp.Direction, sp.RsyncFlags, sp.ExcludePatterns, + boolToInt(sp.Enabled), sp.ID, + ) + return err +} + +func (r *SyncPairRepository) Delete(id int64) error { + _, err := r.db.Exec("DELETE FROM sync_pairs WHERE id = ?", id) + return err +} + +func (r *SyncPairRepository) GetEnabled() ([]SyncPair, error) { + rows, err := r.db.Query(` + SELECT id, name, source_machine_id, source_path, dest_machine_id, dest_path, + direction, rsync_flags, exclude_patterns, enabled, created_at + FROM sync_pairs WHERE enabled = 1`) + if err != nil { + return nil, err + } + defer rows.Close() + + var pairs []SyncPair + for rows.Next() { + var p SyncPair + var srcID, dstID sql.NullInt64 + if err := rows.Scan(&p.ID, &p.Name, &srcID, &p.SourcePath, &dstID, + &p.DestPath, &p.Direction, &p.RsyncFlags, &p.ExcludePatterns, + &p.Enabled, &p.CreatedAt); err != nil { + return nil, err + } + if srcID.Valid { + v := srcID.Int64 + p.SourceMachineID = &v + } + if dstID.Valid { + v := dstID.Int64 + p.DestMachineID = &v + } + p.Enabled = true + pairs = append(pairs, p) + } + return pairs, rows.Err() +} + +func (sp *SyncPair) ExcludePatternsList() []string { + if sp.ExcludePatterns == "" { + return nil + } + var patterns []string + for _, p := range strings.Split(sp.ExcludePatterns, "\n") { + p = strings.TrimSpace(p) + if p != "" { + patterns = append(patterns, p) + } + } + return patterns +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..2d01fdf --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,66 @@ +package models + +import ( + "database/sql" + "time" +) + +type User struct { + ID int64 `db:"id" json:"id"` + Username string `db:"username" json:"username"` + PasswordHash string `db:"password_hash" json:"-"` + Role string `db:"role" json:"role"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type UserRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(username, passwordHash, role string) (int64, error) { + res, err := r.db.Exec( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + username, passwordHash, role, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (r *UserRepository) GetByUsername(username string) (*User, error) { + var u User + err := r.db.QueryRow( + "SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?", + username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *UserRepository) Exists() (bool, error) { + var n int + err := r.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +func (r *UserRepository) GetByID(id int64) (*User, error) { + var u User + err := r.db.QueryRow( + "SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?", + id, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} diff --git a/internal/scheduler/cron.go b/internal/scheduler/cron.go new file mode 100644 index 0000000..b531dde --- /dev/null +++ b/internal/scheduler/cron.go @@ -0,0 +1,153 @@ +package scheduler + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type CronExpr struct { + Minute []int + Hour []int + DayOfMonth []int + Month []int + DayOfWeek []int +} + +func ParseCron(expr string) (*CronExpr, error) { + parts := strings.Fields(expr) + if len(parts) != 5 { + return nil, fmt.Errorf("expected 5 fields, got %d", len(parts)) + } + + minute, err := parseField(parts[0], 0, 59) + if err != nil { + return nil, fmt.Errorf("minute: %w", err) + } + hour, err := parseField(parts[1], 0, 23) + if err != nil { + return nil, fmt.Errorf("hour: %w", err) + } + dom, err := parseField(parts[2], 1, 31) + if err != nil { + return nil, fmt.Errorf("day of month: %w", err) + } + month, err := parseField(parts[3], 1, 12) + if err != nil { + return nil, fmt.Errorf("month: %w", err) + } + dow, err := parseField(parts[4], 0, 6) + if err != nil { + return nil, fmt.Errorf("day of week: %w", err) + } + + return &CronExpr{ + Minute: minute, + Hour: hour, + DayOfMonth: dom, + Month: month, + DayOfWeek: dow, + }, nil +} + +func parseField(field string, min, max int) ([]int, error) { + if field == "*" { + var vals []int + for i := min; i <= max; i++ { + vals = append(vals, i) + } + return vals, nil + } + + var result []int + parts := strings.Split(field, ",") + for _, part := range parts { + if strings.Contains(part, "/") { + stepParts := strings.Split(part, "/") + if len(stepParts) != 2 { + return nil, fmt.Errorf("invalid step: %s", part) + } + rangePart := stepParts[0] + step, err := strconv.Atoi(stepParts[1]) + if err != nil { + return nil, fmt.Errorf("invalid step value: %s", stepParts[1]) + } + var start, end int + if rangePart == "*" { + start, end = min, max + } else if strings.Contains(rangePart, "-") { + rp := strings.Split(rangePart, "-") + if len(rp) != 2 { + return nil, fmt.Errorf("invalid range: %s", rangePart) + } + start, _ = strconv.Atoi(rp[0]) + end, _ = strconv.Atoi(rp[1]) + } else { + v, _ := strconv.Atoi(rangePart) + start, end = v, v + } + for i := start; i <= end; i += step { + result = append(result, i) + } + } else if strings.Contains(part, "-") { + rp := strings.Split(part, "-") + if len(rp) != 2 { + return nil, fmt.Errorf("invalid range: %s", part) + } + start, _ := strconv.Atoi(rp[0]) + end, _ := strconv.Atoi(rp[1]) + for i := start; i <= end; i++ { + result = append(result, i) + } + } else { + v, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid value: %s", part) + } + if v < min || v > max { + return nil, fmt.Errorf("value %d out of range [%d,%d]", v, min, max) + } + result = append(result, v) + } + } + return result, nil +} + +func (c *CronExpr) Matches(t time.Time) bool { + if !contains(c.Minute, t.Minute()) { + return false + } + if !contains(c.Hour, t.Hour()) { + return false + } + if !contains(c.DayOfMonth, t.Day()) { + return false + } + if !contains(c.Month, int(t.Month())) { + return false + } + if !contains(c.DayOfWeek, int(t.Weekday())) { + return false + } + return true +} + +func contains(slice []int, val int) bool { + for _, v := range slice { + if v == val { + return true + } + } + return false +} + +func NextRun(expr *CronExpr, from time.Time) time.Time { + for i := 1; i <= 525600; i++ { + t := from.Add(time.Duration(i) * time.Minute) + if expr.Matches(t) { + return t + } + } + return from.AddDate(0, 0, 1) +} diff --git a/internal/scheduler/cron_test.go b/internal/scheduler/cron_test.go new file mode 100644 index 0000000..8db068b --- /dev/null +++ b/internal/scheduler/cron_test.go @@ -0,0 +1,97 @@ +package scheduler + +import ( + "testing" + "time" +) + +func TestParseCron(t *testing.T) { + tests := []struct { + expr string + wantErr bool + }{ + {"* * * * *", false}, + {"0 * * * *", false}, + {"*/5 * * * *", false}, + {"0,30 * * * *", false}, + {"0-30 * * * *", false}, + {"*/15 9-17 * * *", false}, + {"0 0 1 * *", false}, + {"0 0 * * 0", false}, + {"0 0 1,15 * *", false}, + {"invalid", true}, + {"* * * *", true}, + {"60 * * * *", true}, + {"* 24 * * *", true}, + } + + for _, tt := range tests { + _, err := ParseCron(tt.expr) + if (err != nil) != tt.wantErr { + t.Errorf("ParseCron(%q) error = %v, wantErr %v", tt.expr, err, tt.wantErr) + } + } +} + +func TestCronMatches(t *testing.T) { + expr, err := ParseCron("*/5 * * * *") + if err != nil { + t.Fatal(err) + } + + tests := []struct { + minute int + want bool + }{ + {0, true}, + {5, true}, + {10, true}, + {15, true}, + {1, false}, + {2, false}, + {7, false}, + } + + now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + for _, tt := range tests { + tm := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), tt.minute, 0, 0, time.UTC) + if got := expr.Matches(tm); got != tt.want { + t.Errorf("Matches(minute=%d) = %v, want %v", tt.minute, got, tt.want) + } + } +} + +func TestCronMatchesSpecific(t *testing.T) { + expr, err := ParseCron("30 9 15 * *") + if err != nil { + t.Fatal(err) + } + + matches := time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC) + if !expr.Matches(matches) { + t.Error("should match 9:30 on 15th of month") + } + + notMatch := time.Date(2024, 6, 16, 9, 30, 0, 0, time.UTC) + if expr.Matches(notMatch) { + t.Error("should not match 9:30 on 16th of month") + } +} + +func TestNextRun(t *testing.T) { + expr, err := ParseCron("*/5 * * * *") + if err != nil { + t.Fatal(err) + } + + from := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + next := NextRun(expr, from) + + if next.Minute() != 5 || next.Hour() != 12 { + t.Errorf("NextRun = %v, want 12:05", next) + } + + if !next.After(from) { + t.Error("NextRun should be after from time") + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..6f25c66 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,95 @@ +package scheduler + +import ( + "context" + "database/sql" + "log/slog" + "sync" + "time" + + "github.com/syncserver/internal/config" + "github.com/syncserver/internal/models" + "github.com/syncserver/internal/syncengine" +) + +type Scheduler struct { + db *sql.DB + engine *syncengine.Engine + cfg *config.Config + stopCh chan struct{} + wg sync.WaitGroup +} + +func New(database interface{ SQLDB() *sql.DB }, engine *syncengine.Engine, cfg *config.Config) *Scheduler { + return &Scheduler{ + db: database.SQLDB(), + engine: engine, + cfg: cfg, + stopCh: make(chan struct{}), + } +} + +func (s *Scheduler) Start() { + s.wg.Add(1) + go s.run() + slog.Info("scheduler started") +} + +func (s *Scheduler) Stop() { + close(s.stopCh) + s.wg.Wait() + slog.Info("scheduler stopped") +} + +func (s *Scheduler) run() { + defer s.wg.Done() + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.tick() + } + } +} + +func (s *Scheduler) tick() { + scheduleRepo := models.NewScheduleRepository(s.db) + now := time.Now().UTC() + + schedules, err := scheduleRepo.GetEnabledDue(now) + if err != nil { + slog.Error("scheduler: failed to get due schedules", "error", err) + return + } + + for _, sch := range schedules { + pairRepo := models.NewSyncPairRepository(s.db) + pair, err := pairRepo.GetByID(sch.SyncPairID) + if err != nil || !pair.Enabled { + continue + } + + jobID, err := s.engine.CreateJob(sch.SyncPairID, "scheduled") + if err != nil { + slog.Error("scheduler: failed to create job", "schedule_id", sch.ID, "error", err) + continue + } + + ctx := context.Background() + go func(jobID int64, pairID int64, schID int64) { + if err := s.engine.Run(ctx, jobID, pairID); err != nil { + slog.Warn("scheduler: job failed", "job_id", jobID, "error", err) + } + + expr, _ := ParseCron(sch.CronExpr) + if expr != nil { + next := NextRun(expr, time.Now().UTC()) + scheduleRepo.UpdateNextRun(schID, next) + } + }(jobID, sch.SyncPairID, sch.ID) + } +} diff --git a/internal/sshmanager/keys.go b/internal/sshmanager/keys.go new file mode 100644 index 0000000..151f035 --- /dev/null +++ b/internal/sshmanager/keys.go @@ -0,0 +1,68 @@ +package sshmanager + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ServerKeyLabel = "server" + +func EnsureServerKey(sshDir string) (privPath, pubPath string, pubKey string, err error) { + if err := os.MkdirAll(sshDir, 0700); err != nil { + return "", "", "", fmt.Errorf("creating ssh dir: %w", err) + } + + privPath = filepath.Join(sshDir, "id_ed25519") + pubPath = filepath.Join(sshDir, "id_ed25519.pub") + + if _, err := os.Stat(privPath); os.IsNotExist(err) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", "", fmt.Errorf("generating ed25519 key: %w", err) + } + + privFile, err := os.OpenFile(privPath, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return "", "", "", fmt.Errorf("creating private key file: %w", err) + } + defer privFile.Close() + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return "", "", "", fmt.Errorf("marshaling private key: %w", err) + } + pem.Encode(privFile, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) + + pubKey = fmt.Sprintf("%s %s", strings.TrimSpace(string(pub)), "syncserver") + if err := os.WriteFile(pubPath, []byte(pubKey), 0644); err != nil { + return "", "", "", fmt.Errorf("writing public key: %w", err) + } + return privPath, pubPath, pubKey, nil + } else if err != nil { + return "", "", "", fmt.Errorf("checking private key: %w", err) + } + + data, err := os.ReadFile(pubPath) + if err != nil { + return "", "", "", fmt.Errorf("reading public key: %w", err) + } + return privPath, pubPath, strings.TrimSpace(string(data)), nil +} + +func ReadPrivateKey(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + return block.Bytes, nil +} diff --git a/internal/sshmanager/knownhosts.go b/internal/sshmanager/knownhosts.go new file mode 100644 index 0000000..1dac65f --- /dev/null +++ b/internal/sshmanager/knownhosts.go @@ -0,0 +1,143 @@ +package sshmanager + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +type KnownHost struct { + Host string + Port int + KeyType string + Fingerprint string +} + +func EnsureKnownHosts(sshDir string) (string, error) { + path := filepath.Join(sshDir, "known_hosts") + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0644) + if err != nil { + return "", err + } + f.Close() + return path, nil +} + +func AddKnownHost(sshDir, host string, port int, keyData []byte) error { + path := filepath.Join(sshDir, "known_hosts") + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + addr := host + if port != 22 { + addr = fmt.Sprintf("[%s]:%d", host, port) + } + + line := fmt.Sprintf("%s %s\n", addr, strings.TrimSpace(string(keyData))) + if _, err := f.WriteString(line); err != nil { + return err + } + return nil +} + +func parseHostPort(entry string) (string, int) { + if strings.HasPrefix(entry, "[") { + var h string + var p int + if n, _ := fmt.Sscanf(entry, "[%[^]]]:%d", &h, &p); n == 2 { + return h, p + } + } + parts := strings.Split(entry, ":") + if len(parts) == 2 { + return parts[0], 22 + } + return entry, 22 +} + +func GetKnownHost(sshDir, host string, port int) (*KnownHost, error) { + path := filepath.Join(sshDir, "known_hosts") + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var targetAddr string + if port != 22 { + targetAddr = fmt.Sprintf("[%s]:%d", host, port) + } else { + targetAddr = host + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + h, p := parseHostPort(parts[0]) + if (h == host || parts[0] == targetAddr) && p == port { + return &KnownHost{ + Host: h, + Port: p, + KeyType: parts[1], + Fingerprint: parts[1] + " " + parts[2], + }, nil + } + } + return nil, nil +} + +func HasKnownHost(sshDir, host string, port int) (bool, error) { + kh, err := GetKnownHost(sshDir, host, port) + if err != nil { + return false, err + } + return kh != nil, nil +} + +func RemoveKnownHost(sshDir, host string, port int) error { + path := filepath.Join(sshDir, "known_hosts") + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + var lines []string + targetAddr := host + if port != 22 { + targetAddr = fmt.Sprintf("[%s]:%d", host, port) + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + h, p := parseHostPort(line) + if h == host && p == port { + continue + } + if line == targetAddr { + continue + } + lines = append(lines, line) + } + + tmp := path + ".tmp" + wf, err := os.Create(tmp) + if err != nil { + return err + } + for _, l := range lines { + wf.WriteString(l + "\n") + } + wf.Close() + return os.Rename(tmp, path) +} diff --git a/internal/sshmanager/testconn.go b/internal/sshmanager/testconn.go new file mode 100644 index 0000000..6d7677d --- /dev/null +++ b/internal/sshmanager/testconn.go @@ -0,0 +1,107 @@ +package sshmanager + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +type ConnResult struct { + Success bool + Output string + Error string + Fingerprint string +} + +func TestSSHConnection(ctx context.Context, host string, port int, user, privKeyPath, knownHostsPath string, strictHostKeyChecking bool) (*ConnResult, error) { + addr := fmt.Sprintf("%s:%d", host, port) + + auths := []ssh.AuthMethod{} + if privKeyPath != "" { + key, err := os.ReadFile(privKeyPath) + if err != nil { + return nil, fmt.Errorf("reading private key: %w", err) + } + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, fmt.Errorf("parsing private key: %w", err) + } + auths = append(auths, ssh.PublicKeys(signer)) + } + + hostKeyPolicy := ssh.InsecureIgnoreHostKey() + if strictHostKeyChecking && knownHostsPath != "" { + hostKeyCallback, err := getHostKeyCallback(knownHostsPath, host, port) + if err != nil { + return &ConnResult{Success: false, Error: fmt.Sprintf("known_hosts: %v", err)}, nil + } + hostKeyPolicy = hostKeyCallback + } + + cfg := &ssh.ClientConfig{ + User: user, + Auth: auths, + HostKeyCallback: hostKeyPolicy, + Timeout: 10 * time.Second, + } + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + conn, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + if strings.Contains(err.Error(), "known_hosts") || strings.Contains(err.Error(), "host key") { + return &ConnResult{ + Success: false, + Error: fmt.Sprintf("host key verification failed: %v", err), + }, nil + } + return &ConnResult{ + Success: false, + Error: fmt.Sprintf("connection failed: %v", err), + }, nil + } + defer conn.Close() + + session, err := conn.NewSession() + if err != nil { + return &ConnResult{Success: false, Error: fmt.Sprintf("session: %v", err)}, nil + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + if err := session.Run("echo ok && uname -a"); err != nil { + return &ConnResult{ + Success: false, + Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()), + }, nil + } + + return &ConnResult{ + Success: true, + Output: stdout.String(), + }, nil +} + +func getHostKeyCallback(knownHostsPath, host string, port int) (ssh.HostKeyCallback, error) { + return ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { + kh, err := GetKnownHost(filepath.Dir(knownHostsPath), host, port) + if err != nil { + return fmt.Errorf("checking known_hosts: %w", err) + } + if kh == nil { + return fmt.Errorf("host key not found in known_hosts: %s", hostname) + } + return nil + }), nil +} diff --git a/internal/syncengine/engine.go b/internal/syncengine/engine.go new file mode 100644 index 0000000..7da2ca1 --- /dev/null +++ b/internal/syncengine/engine.go @@ -0,0 +1,223 @@ +package syncengine + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "github.com/syncserver/internal/config" + "github.com/syncserver/internal/models" + "github.com/syncserver/internal/wol" +) + +type Engine struct { + db *sql.DB + cfg *config.Config + queue *Queue + eventBus chan Event + mu sync.RWMutex + stopped bool +} + +type Event struct { + Type string + JobID int64 + Status string + Line string + Stream string +} + +func New(database interface{ SQLDB() *sql.DB }, cfg *config.Config) *Engine { + e := &Engine{ + db: database.SQLDB(), + cfg: cfg, + queue: NewQueue(), + eventBus: make(chan Event, 100), + } + return e +} + +func (e *Engine) Start() {} +func (e *Engine) Stop() {} + +func (e *Engine) Events() <-chan Event { + return e.eventBus +} + +func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error { + if e.queue.IsRunning(pairID) { + existingJobID, _ := e.queue.GetJobID(pairID) + return fmt.Errorf("%w: job %d is already running", ErrAlreadyRunning, existingJobID) + } + + jobCtx, cancel := context.WithCancel(ctx) + enqueueErr := e.queue.Enqueue(pairID, jobID, cancel) + if enqueueErr != nil { + return enqueueErr + } + defer e.queue.Dequeue(pairID) + + pairRepo := models.NewSyncPairRepository(e.db) + pair, err := pairRepo.GetByID(pairID) + if err != nil { + return fmt.Errorf("fetching sync pair: %w", err) + } + + machineRepo := models.NewMachineRepository(e.db) + var srcMachine, dstMachine *models.Machine + + if pair.SourceMachineID != nil { + m, _ := machineRepo.GetByID(*pair.SourceMachineID) + srcMachine = m + } + if pair.DestMachineID != nil { + m, _ := machineRepo.GetByID(*pair.DestMachineID) + dstMachine = m + } + + src := buildPath(pair.SourcePath, srcMachine) + dst := buildPath(pair.DestPath, dstMachine) + + cfg := &SyncPairConfig{ + ID: pair.ID, + Name: pair.Name, + SourceMachineID: pair.SourceMachineID, + SourcePath: pair.SourcePath, + DestMachineID: pair.DestMachineID, + DestPath: pair.DestPath, + Direction: pair.Direction, + RsyncFlags: pair.RsyncFlags, + ExcludePatterns: pair.ExcludePatternsList(), + } + cfg.Source = src + cfg.Dest = dst + + logPath := filepath.Join(e.cfg.LogsDir(), fmt.Sprintf("%d.log", jobID)) + f, err := os.Create(logPath) + if err != nil { + slog.Warn("failed to create log file", "error", err) + } else { + f.Close() + } + + e.setJobStatus(jobID, "waking_up") + e.emit(Event{Type: "status", JobID: jobID, Status: "waking_up"}) + + var targetMachine *models.Machine + var remotePort int + if pair.Direction == "pull" && srcMachine != nil { + targetMachine = srcMachine + remotePort = srcMachine.Port + } else if dstMachine != nil { + targetMachine = dstMachine + remotePort = dstMachine.Port + } + + if targetMachine != nil && targetMachine.WoLEnabled && targetMachine.MACAddress != nil { + mac, err := wol.ParseMAC(*targetMachine.MACAddress) + if err == nil { + bcast := "" + if targetMachine.BroadcastAddr != nil { + bcast = *targetMachine.BroadcastAddr + } + if err := wol.Send(targetMachine.Host, mac, bcast); err != nil { + slog.Warn("WoL failed", "host", targetMachine.Host, "error", err) + } else { + slog.Info("WoL magic packet sent", "host", targetMachine.Host, "mac", *targetMachine.MACAddress) + } + + timeout := time.Duration(targetMachine.WakeTimeoutSeconds) * time.Second + interval := time.Duration(targetMachine.WakeCheckIntervalSeconds) * time.Second + if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval, false); err != nil { + e.setJobStatus(jobID, "failed") + e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: err.Error()}) + return fmt.Errorf("machine not ready: %w", err) + } + } + } + + e.setJobStatus(jobID, "running") + e.setJobLogFile(jobID, logPath) + e.emit(Event{Type: "status", JobID: jobID, Status: "running"}) + slog.Info("job started", "job_id", jobID, "pair", pair.Name) + + var privKey string + if targetMachine != nil && targetMachine.SSHKeyID != nil { + privKey = filepath.Join(e.cfg.SSHDir(), "id_ed25519") + } + + runner := NewRsyncRunner(e.cfg.SSHDir(), privKey) + onLine := func(stream, line string) { + e.emit(Event{Type: "log", JobID: jobID, Stream: stream, Line: line}) + } + + result, err := runner.Run(jobCtx, cfg, onLine) + if err != nil { + if jobCtx.Err() != nil { + e.setJobStatus(jobID, "cancelled") + e.emit(Event{Type: "status", JobID: jobID, Status: "cancelled"}) + return jobCtx.Err() + } + e.setJobStatus(jobID, "failed") + e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: err.Error()}) + return fmt.Errorf("rsync error: %w", err) + } + + if result.ExitCode != 0 { + e.setJobStatus(jobID, "failed") + e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: result.Stderr}) + return fmt.Errorf("rsync exited with code %d: %s", result.ExitCode, result.Stderr) + } + + e.setJobStatus(jobID, "success") + e.emit(Event{Type: "status", JobID: jobID, Status: "success"}) + slog.Info("job completed", "job_id", jobID, "pair", pair.Name) + return nil +} + +func (e *Engine) Cancel(jobID int64, syncPairID int64) bool { + if e.queue.IsRunning(syncPairID) { + e.queue.Cancel(syncPairID) + return true + } + return false +} + +func (e *Engine) setJobStatus(jobID int64, status string) { + jobRepo := models.NewJobRepository(e.db) + jobRepo.UpdateStatus(jobID, status) +} + +func (e *Engine) setJobLogFile(jobID int64, path string) { + jobRepo := models.NewJobRepository(e.db) + jobRepo.SetLogFile(jobID, path) +} + +func (e *Engine) emit(evt Event) { + select { + case e.eventBus <- evt: + default: + slog.Warn("event bus full, dropping event", "type", evt.Type) + } +} + +func buildPath(path string, machine *models.Machine) string { + if machine == nil { + return path + } + return fmt.Sprintf("%s@%s:%s", machine.SSHUser, machine.Host, path) +} + +func (e *Engine) CreateJob(syncPairID int64, triggerType string) (int64, error) { + jobRepo := models.NewJobRepository(e.db) + id, err := jobRepo.Create(syncPairID, triggerType, "queued") + if err != nil { + return 0, err + } + return id, nil +} diff --git a/internal/syncengine/parser.go b/internal/syncengine/parser.go new file mode 100644 index 0000000..a5b04bc --- /dev/null +++ b/internal/syncengine/parser.go @@ -0,0 +1,76 @@ +package syncengine + +import ( + "regexp" + "strconv" + "strings" +) + +type RsyncStats struct { + SentBytes int64 + ReceivedBytes int64 + TotalSize int64 + Speedup float64 + FilesSent int + FilesTotal int +} + +type ProgressLine struct { + Phase string + Percent float64 + Files int + Total int + SentBytes int64 + XferedBytes int64 +} + +var ( + progressRegex = regexp.MustCompile(`\s*([\d,]+)\s+([\d,]+)\s+([\d%]+)\s*`) + sentRegex = regexp.MustCompile(`sent\s+([\d,]+)\s+bytes`) + recvRegex = regexp.MustCompile(`received\s+([\d,]+)\s+bytes`) + totalRegex = regexp.MustCompile(`total size is\s+([\d,]+)`) + filesRegex = regexp.MustCompile(`Number of files: ([\d,]+)`) +) + +func ParseProgressLine(line string) *ProgressLine { + if strings.Contains(line, "files to consider") || strings.Contains(line, "files...") { + return &ProgressLine{Phase: "scanning"} + } + if strings.Contains(line, "building file list") { + return &ProgressLine{Phase: "listing"} + } + if strings.Contains(line, "sent") && strings.Contains(line, "bytes") { + return &ProgressLine{Phase: "stats"} + } + return nil +} + +func ParseStatsLine(line string) (int64, bool) { + m := sentRegex.FindStringSubmatch(line) + if len(m) >= 2 { + n, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) + return n, true + } + return 0, false +} + +func ParseFinalStats(output string) *RsyncStats { + stats := &RsyncStats{} + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if m := sentRegex.FindStringSubmatch(line); len(m) >= 2 { + stats.SentBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) + } + if m := recvRegex.FindStringSubmatch(line); len(m) >= 2 { + stats.ReceivedBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) + } + if m := totalRegex.FindStringSubmatch(line); len(m) >= 2 { + stats.TotalSize, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64) + } + if m := filesRegex.FindStringSubmatch(line); len(m) >= 2 { + stats.FilesTotal, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", "")) + } + } + return stats +} diff --git a/internal/syncengine/queue.go b/internal/syncengine/queue.go new file mode 100644 index 0000000..6812e04 --- /dev/null +++ b/internal/syncengine/queue.go @@ -0,0 +1,63 @@ +package syncengine + +import ( + "errors" + "sync" +) + +var ErrAlreadyRunning = errors.New("job already running for this sync pair") + +type Queue struct { + mu sync.Mutex + runs map[int64]*RunInfo +} + +type RunInfo struct { + JobID int64 + Cancel func() +} + +func NewQueue() *Queue { + return &Queue{runs: make(map[int64]*RunInfo)} +} + +func (q *Queue) Enqueue(syncPairID, jobID int64, cancel func()) error { + q.mu.Lock() + defer q.mu.Unlock() + if _, exists := q.runs[syncPairID]; exists { + return ErrAlreadyRunning + } + q.runs[syncPairID] = &RunInfo{JobID: jobID, Cancel: cancel} + return nil +} + +func (q *Queue) Dequeue(syncPairID int64) { + q.mu.Lock() + defer q.mu.Unlock() + delete(q.runs, syncPairID) +} + +func (q *Queue) IsRunning(syncPairID int64) bool { + q.mu.Lock() + defer q.mu.Unlock() + _, exists := q.runs[syncPairID] + return exists +} + +func (q *Queue) GetJobID(syncPairID int64) (int64, bool) { + q.mu.Lock() + defer q.mu.Unlock() + info, exists := q.runs[syncPairID] + if !exists { + return 0, false + } + return info.JobID, true +} + +func (q *Queue) Cancel(syncPairID int64) { + q.mu.Lock() + defer q.mu.Unlock() + if info, exists := q.runs[syncPairID]; exists && info.Cancel != nil { + info.Cancel() + } +} diff --git a/internal/syncengine/queue_test.go b/internal/syncengine/queue_test.go new file mode 100644 index 0000000..22db185 --- /dev/null +++ b/internal/syncengine/queue_test.go @@ -0,0 +1,45 @@ +package syncengine + +import ( + "testing" +) + +func TestQueue(t *testing.T) { + q := NewQueue() + + if q.IsRunning(1) { + t.Error("queue should be empty") + } + + cancelCalled := false + cancel := func() { cancelCalled = true } + + err := q.Enqueue(1, 100, cancel) + if err != nil { + t.Errorf("Enqueue(1) unexpected error: %v", err) + } + + if !q.IsRunning(1) { + t.Error("queue should contain syncPair 1") + } + + jobID, ok := q.GetJobID(1) + if !ok || jobID != 100 { + t.Errorf("GetJobID(1) = %d, %v, want 100, true", jobID, ok) + } + + err = q.Enqueue(1, 200, nil) + if err != ErrAlreadyRunning { + t.Errorf("Enqueue(1) again = %v, want ErrAlreadyRunning", err) + } + + q.Cancel(1) + if !cancelCalled { + t.Error("Cancel should have called the cancel func") + } + + q.Dequeue(1) + if q.IsRunning(1) { + t.Error("queue should be empty after Dequeue") + } +} diff --git a/internal/syncengine/rsync_runner.go b/internal/syncengine/rsync_runner.go new file mode 100644 index 0000000..89d797f --- /dev/null +++ b/internal/syncengine/rsync_runner.go @@ -0,0 +1,157 @@ +package syncengine + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" +) + +type RsyncResult struct { + ExitCode int + Stdout string + Stderr string + Stats *RsyncStats +} + +type RsyncRunner struct { + sshDir string + privKey string +} + +type SyncPairConfig struct { + ID int64 + Name string + SourceMachineID *int64 + SourcePath string + DestMachineID *int64 + DestPath string + Direction string + RsyncFlags string + ExcludePatterns []string + Source string + Dest string +} + +func NewRsyncRunner(sshDir, privKey string) *RsyncRunner { + return &RsyncRunner{sshDir: sshDir, privKey: privKey} +} + +func (r *RsyncRunner) Run(ctx context.Context, pair *SyncPairConfig, onLine func(stream string, line string)) (*RsyncResult, error) { + args := r.buildArgs(pair) + + cmd := exec.CommandContext(ctx, "rsync", args...) + if r.privKey != "" { + sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s", + r.privKey, strings.TrimRight(r.sshDir, "/")+"/known_hosts") + cmd.Args = append([]string{"rsync", "-e", sshCmd}, args[1:]...) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("starting rsync: %w", err) + } + + var outLines, errLines []string + done := make(chan struct{}) + go func() { + br := io.Reader(stdout) + buf := make([]byte, 4096) + for { + n, err := br.Read(buf) + if n > 0 { + line := strings.TrimRight(string(buf[:n]), "\r\n") + if line != "" { + outLines = append(outLines, line) + if onLine != nil { + onLine("stdout", line) + } + } + } + if err != nil { + break + } + } + select { + case <-done: + default: + close(done) + } + }() + + go func() { + br := io.Reader(stderr) + buf := make([]byte, 4096) + for { + n, err := br.Read(buf) + if n > 0 { + line := strings.TrimRight(string(buf[:n]), "\r\n") + if line != "" { + errLines = append(errLines, line) + if onLine != nil { + onLine("stderr", line) + } + } + } + if err != nil { + break + } + } + select { + case <-done: + default: + close(done) + } + }() + + err = cmd.Wait() + <-done + + result := &RsyncResult{ + ExitCode: 0, + Stdout: strings.Join(outLines, "\n"), + Stderr: strings.Join(errLines, "\n"), + Stats: ParseFinalStats(strings.Join(outLines, "\n")), + } + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = -1 + } + } + + return result, nil +} + +func (r *RsyncRunner) buildArgs(pair *SyncPairConfig) []string { + var args []string + + flags := strings.Fields(pair.RsyncFlags) + args = append(args, flags...) + + for _, pattern := range pair.ExcludePatterns { + args = append(args, "--exclude="+pattern) + } + + if pair.Direction == "mirror" { + args = append(args, "--delete") + } + + if pair.Direction == "pull" { + args = append(args, pair.Dest, pair.Source) + } else { + args = append(args, pair.Source, pair.Dest) + } + + return args +} diff --git a/internal/webui/embed.go b/internal/webui/embed.go new file mode 100644 index 0000000..276393d --- /dev/null +++ b/internal/webui/embed.go @@ -0,0 +1,43 @@ +package webui + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed dist +var DistFS embed.FS + +var Dist fs.FS = DistFS + +func ServeHTTP() http.Handler { + return http.FileServer(http.FS(DistFS)) +} + +func ServeSPA() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == "/" || !isStaticAsset(path) { + data, err := DistFS.ReadFile("dist/index.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(data) + return + } + http.FileServer(http.FS(DistFS)).ServeHTTP(w, r) + }) +} + +func isStaticAsset(path string) bool { + exts := []string{".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".map"} + for _, ext := range exts { + if len(path) > len(ext) && path[len(path)-len(ext):] == ext { + return true + } + } + return false +} diff --git a/internal/wol/waiter.go b/internal/wol/waiter.go new file mode 100644 index 0000000..996a48e --- /dev/null +++ b/internal/wol/waiter.go @@ -0,0 +1,68 @@ +package wol + +import ( + "context" + "fmt" + "net" + "os/exec" + "time" +) + +var ErrTimeout = fmt.Errorf("timeout waiting for machine to respond") + +func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, checkInterval time.Duration, usePing bool) error { + deadline := time.Now().Add(maxWait) + interval := checkInterval + + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + + addr := fmt.Sprintf("%s:%d", host, sshPort) + dialer := net.Dialer{Timeout: 3 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err == nil { + conn.Close() + return nil + } + + if usePing { + cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host) + if err := cmd.Run(); err == nil { + return nil + } + } + + if time.Now().After(deadline) { + return ErrTimeout + } + + elapsed := time.Since(time.Now().Add(-maxWait)) + if elapsed > maxWait/2 && interval < 10*time.Second { + interval = interval * 3 / 2 + if interval > 10*time.Second { + interval = 10 * time.Second + } + ticker.Reset(interval) + } + } +} + +func IsHostReachable(host string, port int, timeout time.Duration) bool { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + addr := fmt.Sprintf("%s:%d", host, port) + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err == nil { + conn.Close() + return true + } + return false +} diff --git a/internal/wol/wol.go b/internal/wol/wol.go new file mode 100644 index 0000000..fdf4e4a --- /dev/null +++ b/internal/wol/wol.go @@ -0,0 +1,80 @@ +package wol + +import ( + "fmt" + "net" + "regexp" + "strings" + "time" +) + +var macRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$`) + +func ParseMAC(s string) ([6]byte, error) { + s = strings.ReplaceAll(s, "-", ":") + s = strings.ToLower(s) + if !macRegex.MatchString(s) { + return [6]byte{}, fmt.Errorf("invalid MAC address: %s", s) + } + parts := strings.Split(s, ":") + var mac [6]byte + for i := 0; i < 6; i++ { + var b int + if _, err := fmt.Sscanf(parts[i], "%x", &b); err != nil { + return [6]byte{}, fmt.Errorf("invalid MAC address: %s", s) + } + mac[i] = byte(b) + } + return mac, nil +} + +func FormatMAC(mac [6]byte) string { + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]) +} + +func BuildMagicPacket(mac [6]byte) []byte { + packet := make([]byte, 6+16*6) + for i := 0; i < 6; i++ { + packet[i] = 0xFF + } + for i := 0; i < 16; i++ { + offset := 6 + i*6 + copy(packet[offset:offset+6], mac[:]) + } + return packet +} + +func Send(addr string, mac [6]byte, broadcastAddr string) error { + packet := BuildMagicPacket(mac) + + udpAddr := &net.UDPAddr{ + IP: net.IPv4bcast, + Port: 9, + } + if broadcastAddr != "" { + udpAddr.IP = net.ParseIP(broadcastAddr) + if udpAddr.IP == nil { + return fmt.Errorf("invalid broadcast address: %s", broadcastAddr) + } + } + + conn, err := net.DialUDP("udp4", nil, udpAddr) + if err != nil { + return fmt.Errorf("creating UDP connection: %w", err) + } + defer conn.Close() + + if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { + return fmt.Errorf("setting broadcast mode: %w", err) + } + + n, err := conn.Write(packet) + if err != nil { + return fmt.Errorf("sending magic packet: %w", err) + } + if n != len(packet) { + return fmt.Errorf("incomplete write: sent %d/%d bytes", n, len(packet)) + } + return nil +} diff --git a/internal/wol/wol_test.go b/internal/wol/wol_test.go new file mode 100644 index 0000000..94a1a47 --- /dev/null +++ b/internal/wol/wol_test.go @@ -0,0 +1,72 @@ +package wol + +import ( + "testing" +) + +func TestParseMAC(t *testing.T) { + tests := []struct { + input string + wantOK bool + wantBytes [6]byte + }{ + {"AA:BB:CC:DD:EE:FF", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}}, + {"aa:bb:cc:dd:ee:ff", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}}, + {"AA-BB-CC-DD-EE-FF", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}}, + {"11:22:33:44:55:66", true, [6]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66}}, + {"not:a:mac:addre", false, [6]byte{}}, + {"GG:HH:II:JJ:KK:LL", false, [6]byte{}}, + {"aa:bb:cc:dd", false, [6]byte{}}, + {"", false, [6]byte{}}, + } + + for _, tt := range tests { + mac, err := ParseMAC(tt.input) + if tt.wantOK { + if err != nil { + t.Errorf("ParseMAC(%q) unexpected error: %v", tt.input, err) + continue + } + if mac != tt.wantBytes { + t.Errorf("ParseMAC(%q) = %v, want %v", tt.input, mac, tt.wantBytes) + } + } else { + if err == nil { + t.Errorf("ParseMAC(%q) expected error, got nil", tt.input) + } + } + } +} + +func TestFormatMAC(t *testing.T) { + mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + got := FormatMAC(mac) + want := "aa:bb:cc:dd:ee:ff" + if got != want { + t.Errorf("FormatMAC() = %q, want %q", got, want) + } +} + +func TestBuildMagicPacket(t *testing.T) { + mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + packet := BuildMagicPacket(mac) + + if len(packet) != 102 { + t.Errorf("BuildMagicPacket length = %d, want 102", len(packet)) + } + + for i := 0; i < 6; i++ { + if packet[i] != 0xFF { + t.Errorf("packet[%d] = %02x, want FF", i, packet[i]) + } + } + + for i := 0; i < 16; i++ { + offset := 6 + i*6 + for j := 0; j < 6; j++ { + if packet[offset+j] != mac[j] { + t.Errorf("packet[%d] = %02x, want %02x (rep %d)", offset+j, packet[offset+j], mac[j], i) + } + } + } +} diff --git a/packaging/debian/DEBIAN/conffiles b/packaging/debian/DEBIAN/conffiles new file mode 100644 index 0000000..6193980 --- /dev/null +++ b/packaging/debian/DEBIAN/conffiles @@ -0,0 +1 @@ +/etc/syncserver/config.yaml diff --git a/packaging/debian/DEBIAN/control b/packaging/debian/DEBIAN/control new file mode 100644 index 0000000..7ab0076 --- /dev/null +++ b/packaging/debian/DEBIAN/control @@ -0,0 +1,12 @@ +Package: syncserver +Version: 1a66ac5 +Architecture: amd64 +Section: net +Priority: optional +Depends: rsync, openssh-client, ca-certificates +Maintainer: SyncServer Team +Description: Self-hosted file sync orchestrator with Wake-on-LAN and web UI + SyncServer is a Go-based server that provides a web interface to manage + and execute file synchronizations between multiple machines using rsync + over SSH, with optional Wake-on-LAN support to power on remote machines + before syncing. diff --git a/packaging/debian/DEBIAN/postinst b/packaging/debian/DEBIAN/postinst new file mode 100755 index 0000000..753fef5 --- /dev/null +++ b/packaging/debian/DEBIAN/postinst @@ -0,0 +1,47 @@ +#!/bin/bash +set -e + +case "$1" in + configure) + if ! id syncserver > /dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin syncserver || true + fi + + install -d -o syncserver -g syncserver -m 0750 /var/lib/syncserver/data || true + install -d -o syncserver -g syncserver -m 0750 /var/lib/syncserver/ssh || true + install -d -o syncserver -g syncserver -m 0750 /var/lib/syncserver/logs || true + install -d -o root -g root -m 0755 /etc/syncserver || true + + if [ ! -f /etc/syncserver/config.yaml ]; then + cat > /etc/syncserver/config.yaml << 'EOF' +# SyncServer configuration +# Copy this file and edit as needed. +# Environment variables SYNCSERVER_* take precedence over this file. + +data_dir: /var/lib/syncserver +addr: :8080 + +auth: + jwt_secret: "" # Leave empty to generate a random one on startup + jwt_expiry_hours: 24 + +scheduler: + timezone: UTC +EOF + chown root:root /etc/syncserver/config.yaml + chmod 0644 /etc/syncserver/config.yaml + fi + + if command -v systemctl > /dev/null 2>&1; then + systemctl daemon-reload 2>/dev/null || true + systemctl enable syncserver.service 2>/dev/null || true + systemctl start syncserver.service 2>/dev/null || true + fi + ;; + + abort-upgrade|abort-remove|abort-configure) + exit 0 + ;; +esac + +exit 0 diff --git a/packaging/debian/DEBIAN/postrm b/packaging/debian/DEBIAN/postrm new file mode 100755 index 0000000..5e80e0a --- /dev/null +++ b/packaging/debian/DEBIAN/postrm @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +case "$1" in + purge|remove) + if [ "$1" = "purge" ]; then + rm -rf /var/lib/syncserver || true + fi + ;; + upgrade) + ;; + failed-upgrade) + ;; + abort-install|abort-upgrade) + ;; + *) + exit 0 + ;; +esac + +exit 0 diff --git a/packaging/debian/DEBIAN/prerm b/packaging/debian/DEBIAN/prerm new file mode 100755 index 0000000..66679ed --- /dev/null +++ b/packaging/debian/DEBIAN/prerm @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +case "$1" in + remove|purge|deconfigure) + if command -v systemctl > /dev/null 2>&1; then + systemctl stop syncserver.service 2>/dev/null || true + systemctl disable syncserver.service 2>/dev/null || true + fi + ;; + upgrade) + ;; + failed-upgrade) + ;; + *) + exit 0 + ;; +esac + +exit 0 diff --git a/packaging/debian/lib/systemd/system/syncserver.service b/packaging/debian/lib/systemd/system/syncserver.service new file mode 100644 index 0000000..02c3a3b --- /dev/null +++ b/packaging/debian/lib/systemd/system/syncserver.service @@ -0,0 +1,27 @@ +[Unit] +Description=SyncServer - File sync orchestrator with Wake-on-LAN and web UI +Documentation=https://github.com/syncserver/syncserver +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=syncserver +Group=syncserver +ExecStart=/usr/bin/syncserver --config /etc/syncserver/config.yaml --data-dir /var/lib/syncserver +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=syncserver + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/syncserver /run +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..6792484 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}" +GOARCH="${GOARCH:-amd64}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "=== Building SyncServer v${VERSION} for ${GOARCH} ===" + +echo "=== Building frontend ===" +cd "$PROJECT_DIR/web" +npm ci --silent 2>/dev/null || npm install --silent +npm run build + +echo "=== Copying frontend dist to Go embed dir ===" +rm -rf "$PROJECT_DIR/internal/webui/dist" +cp -R "$PROJECT_DIR/web/dist" "$PROJECT_DIR/internal/webui/dist" + +echo "=== Building Go binary ===" +cd "$PROJECT_DIR" +CGO_ENABLED=0 GOOS=linux GOARCH="${GOARCH}" \ + go build -trimpath \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o "packaging/debian/usr/bin/syncserver" \ + ./cmd/server + +echo "=== Binary size ===" +ls -lh "packaging/debian/usr/bin/syncserver" + +echo "=== Done ===" +echo "Run: ./scripts/package-deb.sh to create the .deb package" diff --git a/scripts/package-deb.sh b/scripts/package-deb.sh new file mode 100755 index 0000000..4c28380 --- /dev/null +++ b/scripts/package-deb.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}" +ARCH="${ARCH:-amd64}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$PROJECT_DIR/dist" +DEB_DIR="$PROJECT_DIR/packaging/debian" + +echo "=== Packaging SyncServer v${VERSION} for ${ARCH} ===" + +mkdir -p "$OUTPUT_DIR" + +DEB_PATH="$OUTPUT_DIR/syncserver_${VERSION}_${ARCH}.deb" + +sed "s/@@VERSION@@/${VERSION}/" "$DEB_DIR/DEBIAN/control" > "$DEB_DIR/DEBIAN/control.tmp" +mv "$DEB_DIR/DEBIAN/control.tmp" "$DEB_DIR/DEBIAN/control" + +chmod 0755 "$DEB_DIR/DEBIAN/postinst" +chmod 0755 "$DEB_DIR/DEBIAN/prerm" +chmod 0755 "$DEB_DIR/DEBIAN/postrm" +chmod 0644 "$DEB_DIR/DEBIAN/conffiles" +chmod 0644 "$DEB_DIR/etc/syncserver/config.yaml" +chmod 0644 "$DEB_DIR/lib/systemd/system/syncserver.service" + +echo "=== Building .deb ===" +dpkg-deb --build --root-owner-group "$DEB_DIR" "$DEB_PATH" + +echo "=== Package info ===" +dpkg-deb -I "$DEB_PATH" +echo "" +echo "=== Package contents (first 30) ===" +dpkg-deb -c "$DEB_PATH" | head -30 + +echo "" +echo "=== Output: $DEB_PATH ===" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f7e9539 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + SyncServer + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..982915f --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2633 @@ +{ + "name": "syncserver-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "syncserver-web", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^6.0.1" + } + }, + "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.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "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.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "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.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "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.388", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", + "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", + "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.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.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/vite/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/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..9cc4ce8 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "syncserver-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^6.0.1" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /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..2049276 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,35 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import Machines from './pages/Machines'; +import SyncPairs from './pages/SyncPairs'; +import JobHistory from './pages/JobHistory'; +import Settings from './pages/Settings'; + +function ProtectedRoute({ children }: { children: JSX.Element }) { + const [authed, setAuthed] = useState(null); + useEffect(() => { + fetch('/api/auth/me', { credentials: 'include' }) + .then(r => setAuthed(r.ok)) + .catch(() => setAuthed(false)); + }, []); + if (authed === null) return
Loading...
; + return authed ? children : ; +} + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..e130c0d --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,67 @@ +const BASE = ''; + +interface ApiOptions { + method?: string; + body?: unknown; +} + +export async function api(path: string, opts: ApiOptions = {}): Promise { + const { method = 'GET', body } = opts; + const res = await fetch(`${BASE}${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + credentials: 'include', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((err as { error?: string }).error || 'Request failed'); + } + if (res.status === 204) return undefined as T; + return res.json(); +} + +export interface User { + id: number; + username: string; + role: string; +} + +export interface Machine { + id: number; + name: string; + host: string; + port: number; + ssh_user: string; + ssh_key_id: number | null; + mac_address: string | null; + wol_enabled: boolean; + broadcast_addr: string | null; + wake_timeout_seconds: number; + wake_check_interval_seconds: number; + fingerprint_confirmed: boolean; + status: string; +} + +export interface SyncPair { + id: number; + name: string; + source_machine_id: number | null; + source_path: string; + dest_machine_id: number | null; + dest_path: string; + direction: string; + rsync_flags: string; + exclude_patterns: string; + enabled: boolean; +} + +export interface Job { + id: number; + sync_pair_id: number; + trigger_type: string; + status: string; + started_at: string | null; + finished_at: string | null; + log_file: string | null; +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +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..de02c5c --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api, Machine, Job } from '../api/client'; + +export default function Dashboard() { + const [machines, setMachines] = useState([]); + const [jobs, setJobs] = useState([]); + + useEffect(() => { + Promise.all([ + api('/api/machines'), + api('/api/jobs?limit=5'), + ]).then(([m, j]) => { + setMachines(m); + setJobs(j); + }).catch(() => {}); + }, []); + + const online = machines.filter(m => m.status.startsWith('online')).length; + const todayJobs = jobs.filter(j => { + if (!j.started_at) return false; + return j.started_at.startsWith(new Date().toISOString().split('T')[0]); + }).length; + + return ( +
+

Dashboard

+
+
+
Machines
+
{machines.length}
+
+
+
Online
+
{online}
+
+
+
Jobs Today
+
{todayJobs}
+
+
+
+

Recent Jobs

+ {jobs.length === 0 ?

No jobs yet

: ( + + + + + + + + + + + {jobs.map(j => ( + + + + + + + ))} + +
IDSync PairStatusStarted
{j.id}{j.sync_pair_id} + + {j.started_at ? new Date(j.started_at).toLocaleString() : '-'}
+ )} +
+
+ Machines + Sync Pairs + All Jobs +
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const colors: Record = { + queued: 'bg-gray-600', + waking_up: 'bg-yellow-600', + running: 'bg-blue-600', + success: 'bg-green-600', + failed: 'bg-red-600', + cancelled: 'bg-gray-600', + }; + return ( + + {status} + + ); +} diff --git a/web/src/pages/JobHistory.tsx b/web/src/pages/JobHistory.tsx new file mode 100644 index 0000000..af126cb --- /dev/null +++ b/web/src/pages/JobHistory.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState, useRef } from 'react'; +import { api, Job, SyncPair } from '../api/client'; + +interface SSEEvent { + type: string; + job_id: number; + status?: string; + line?: string; + stream?: string; +} + +export default function JobHistory() { + const [jobs, setJobs] = useState([]); + const [pairs, setPairs] = useState([]); + const esRef = useRef(null); + + useEffect(() => { + load(); + const es = new EventSource('/api/jobs/stream'); + esRef.current = es; + es.onmessage = (e) => { + const evt: SSEEvent = JSON.parse(e.data); + if (evt.type === 'status') { + setJobs(prev => prev.map(j => j.id === evt.job_id ? { ...j, status: evt.status! } : j)); + } + }; + return () => es.close(); + }, []); + + async function load() { + try { + const [j, p] = await Promise.all([ + api('/api/jobs?limit=100'), + api('/api/sync-pairs'), + ]); + setJobs(j); + setPairs(p); + } catch {} + } + + async function cancel(id: number) { + try { + await api(`/api/jobs/${id}/cancel`, { method: 'POST' }); + load(); + } catch { alert('Cancel failed'); } + } + + function pairName(id: number) { + const p = pairs.find(p => p.id === id); + return p ? p.name : `Pair ${id}`; + } + + function statusColor(s: string) { + const map: Record = { + queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600', + success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600', + }; + return map[s] || 'bg-gray-600'; + } + + return ( +
+

Job History

+ + + + + + + + + + + + + + {jobs.map(j => ( + + + + + + + + + + ))} + {jobs.length === 0 && } + +
IDSync PairTriggerStatusStartedFinishedActions
{j.id}{pairName(j.sync_pair_id)}{j.trigger_type} + + {j.status} + + {j.started_at ? new Date(j.started_at).toLocaleString() : '-'}{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'} + {['queued', 'waking_up', 'running'].includes(j.status) && ( + + )} +
No jobs
+
+ ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..8b55d79 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export default function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password }), + }); + if (res.ok) { + navigate('/'); + } else { + const data = await res.json(); + setError(data.error || 'Login failed'); + } + } catch { + setError('Network error'); + } + } + + return ( +
+
+

SyncServer

+ {error &&
{error}
} +
+ + setUsername(e.target.value)} + className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none" + /> +
+
+ + setPassword(e.target.value)} + className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none" + /> +
+ +
+
+ ); +} diff --git a/web/src/pages/Machines.tsx b/web/src/pages/Machines.tsx new file mode 100644 index 0000000..a98e3a1 --- /dev/null +++ b/web/src/pages/Machines.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import { api, Machine } from '../api/client'; + +export default function Machines() { + const [machines, setMachines] = useState([]); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ + id: undefined as number | undefined, name: '', host: '', port: 22, ssh_user: 'root', + mac_address: '', wol_enabled: false, + wake_timeout_seconds: 120, wake_check_interval_seconds: 5, + }); + + useEffect(() => { load(); }, []); + + async function load() { + try { setMachines(await api('/api/machines')); } catch {} + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + try { + const payload: Record = { + id: form.id || null, name: form.name, host: form.host, port: Number(form.port), + ssh_user: form.ssh_user, mac_address: form.mac_address || null, + wol_enabled: Boolean(form.wol_enabled), + wake_timeout_seconds: Number(form.wake_timeout_seconds), + wake_check_interval_seconds: Number(form.wake_check_interval_seconds), + }; + if (form.mac_address && !/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)) { + alert('Invalid MAC address format'); + return; + } + await api(form.id ? `/api/machines/${form.id}` : '/api/machines', { + method: form.id ? 'PUT' : 'POST', + body: payload, + }); + setShowForm(false); + setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', mac_address: '', wol_enabled: false as boolean, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 }); + load(); + } catch (e: unknown) { alert((e as Error).message); } + } + + function edit(m: Machine) { + setForm({ + id: m.id, name: m.name, host: m.host, port: m.port, + ssh_user: m.ssh_user, mac_address: m.mac_address || '', + wol_enabled: m.wol_enabled, + wake_timeout_seconds: m.wake_timeout_seconds, + wake_check_interval_seconds: m.wake_check_interval_seconds, + }); + setShowForm(true); + } + + async function remove(id: number) { + if (!confirm('Delete machine?')) return; + try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); } + } + + return ( +
+
+

Machines

+ +
+ + {showForm && ( +
+
+

Machine

+ setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required /> + setForm({...form, host: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required /> + setForm({...form, port: +e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" /> + setForm({...form, ssh_user: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" /> + setForm({...form, mac_address: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" /> + +
+ + +
+
+
+ )} + + + + + + + + + + + + + {machines.map(m => ( + + + + + + + + ))} + {machines.length === 0 && } + +
NameHostWoLStatusActions
{m.name}{m.host}:{m.port}{m.wol_enabled ? 'Yes' : 'No'}{m.status} + + +
No machines
+
+ ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..9ba1cf2 --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -0,0 +1,50 @@ +import { useState, useEffect } from 'react'; + +export default function Settings() { + const [pubKey, setPubKey] = useState(''); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetch('/api/settings/pubkey', { credentials: 'include' }) + .then(r => r.ok ? r.text() : '') + .then(t => setPubKey(t)) + .catch(() => {}); + }, []); + + function copyKey() { + navigator.clipboard.writeText(pubKey).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( +
+

Settings

+ +
+

Server SSH Public Key

+

+ Add this key to the ~/.ssh/authorized_keys file on your remote machines to allow SyncServer to connect. +

+
+ {pubKey || 'Loading...'} +
+ +
+ +
+

Quick Reference

+
+

ssh-copy-id: Copy the public key above to a remote machine:

+ + cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat >> ~/.ssh/authorized_keys' + +

Wake-on-LAN: Make sure your target machine BIOS/UEFI has WoL enabled and is connected to the same network layer (L2) as this server.

+
+
+
+ ); +} diff --git a/web/src/pages/SyncPairs.tsx b/web/src/pages/SyncPairs.tsx new file mode 100644 index 0000000..2c6031c --- /dev/null +++ b/web/src/pages/SyncPairs.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { api, SyncPair, Machine } from '../api/client'; + +export default function SyncPairs() { + const [pairs, setPairs] = useState([]); + const [machines, setMachines] = useState([]); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ + id: undefined as number | undefined, name: '', source_machine_id: null as number | null, source_path: '', + dest_machine_id: null as number | null, dest_path: '', + direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true, + }); + const [running, setRunning] = useState>({}); + + useEffect(() => { load(); }, []); + + async function load() { + try { + const [p, m] = await Promise.all([ + api('/api/sync-pairs'), + api('/api/machines'), + ]); + setPairs(p); + setMachines(m); + } catch {} + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + try { + const payload = { + name: form.name, source_machine_id: form.source_machine_id, source_path: form.source_path, + dest_machine_id: form.dest_machine_id, dest_path: form.dest_path, + direction: form.direction, rsync_flags: form.rsync_flags, + exclude_patterns: form.exclude_patterns, enabled: form.enabled, + }; + await api(form.id ? `/api/sync-pairs/${form.id}` : '/api/sync-pairs', { + method: form.id ? 'PUT' : 'POST', + body: payload, + }); + setShowForm(false); + resetForm(); + load(); + } catch (e: unknown) { alert((e as Error).message); } + } + + async function trigger(pairId: number) { + setRunning(r => ({ ...r, [pairId]: true })); + try { + await api(`/api/sync-pairs/${pairId}/run`, { method: 'POST' }); + load(); + } catch (e: unknown) { alert((e as Error).message); } + setRunning(r => ({ ...r, [pairId]: false })); + } + + async function remove(id: number) { + if (!confirm('Delete sync pair?')) return; + try { await api(`/api/sync-pairs/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); } + } + + function resetForm() { + setForm({ id: undefined, name: '', source_machine_id: null, source_path: '', dest_machine_id: null, dest_path: '', direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true }); + } + + function machineName(id: number | null) { + if (!id) return 'Local server'; + const m = machines.find(m => m.id === id); + return m ? m.name : `Machine ${id}`; + } + + return ( +
+
+

Sync Pairs

+ +
+ + {showForm && ( +
+
+

Sync Pair

+ setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required /> +
+
+ + +
+
+ + +
+
+
+ setForm({...form, source_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required /> + setForm({...form, dest_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required /> +
+
+ + setForm({...form, rsync_flags: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" /> +
+