mirror of
https://github.com/tronbyt/server.git
synced 2025-12-19 08:25:46 +01:00
Add air (https://github.com/air-verse/air) config for live reload
This commit is contained in:
54
.air.toml
Normal file
54
.air.toml
Normal file
@@ -0,0 +1,54 @@
|
||||
#:schema https://json.schemastore.org/any.json
|
||||
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
cmd = "go build -o ./tronbyt-server ./cmd/server"
|
||||
delay = 1000
|
||||
entrypoint = ["./tronbyt-server"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data", "users"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -18,15 +18,15 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-24.04
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
artifact_name: tronbyt-server-linux-amd64
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-24.04-arm
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
artifact_name: tronbyt-server-linux-arm64
|
||||
- os: macos-latest
|
||||
- os: macos-26
|
||||
goos: darwin
|
||||
goarch: arm64
|
||||
artifact_name: tronbyt-server-darwin-arm64
|
||||
|
||||
@@ -45,6 +45,10 @@ The core functionality involves serving WebP images to devices, generated by ren
|
||||
./tronbyt-server -db users/new.db -data .
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
1. `go test ./...`
|
||||
|
||||
### Docker
|
||||
|
||||
1. **Build:**
|
||||
@@ -58,8 +62,8 @@ The core functionality involves serving WebP images to devices, generated by ren
|
||||
|
||||
## Development Conventions
|
||||
|
||||
* **Formatting:** Use `gofmt` (or `goimports`).
|
||||
* **Linting:** Use `golangci-lint`.
|
||||
* **Formatting:** Use `go fmt ./...` (or `goimports`).
|
||||
* **Linting:** Use `go vet`, `golangci-lint`.
|
||||
* **Logging:** Use `log/slog` for structured logging.
|
||||
* **Templates:** Use `{{ t .Localizer "MessageID" }}` for translated strings.
|
||||
* **Database:** Use GORM for DB interactions. Ensure migrations are updated in `cmd/migrate` or auto-migrated in `server.go` if appropriate.
|
||||
|
||||
26
README.md
26
README.md
@@ -30,10 +30,25 @@ Developing additional clients for Tronbyt Server is straightforward: pull WebP i
|
||||
* For Docker installations, use `docker compose up -d`.
|
||||
* For Homebrew installations: `brew services start tronbyt-server`.
|
||||
* For native installations (from source): `go build -o tronbyt-server ./cmd/server && ./tronbyt-server`
|
||||
* Access the web app at `http://localhost:8000` with default credentials: `admin`/`password`.
|
||||
* Access the web app at `http://localhost:8000`.
|
||||
|
||||
### CLI Commands
|
||||
|
||||
The `tronbyt-server` binary supports additional commands for administration:
|
||||
|
||||
* **`reset-password <username> <new_password>`**: Resets the password for a specified user.
|
||||
```bash
|
||||
./tronbyt-server reset-password admin newsecretpassword
|
||||
```
|
||||
|
||||
* **`health [url]`**: Performs a health check against the running server. Defaults to `http://localhost:8000/health`.
|
||||
```bash
|
||||
./tronbyt-server health
|
||||
./tronbyt-server health https://your-tronbyt-server.com/health
|
||||
```
|
||||
|
||||
**Quick Start Guide:**
|
||||
1. Access the web app at `http://localhost:8000` with `admin`/`password`.
|
||||
1. Access the web app at `http://localhost:8000`.
|
||||
2. Add your Tronbyt as a device.
|
||||
3. Click "Firmware," enter WiFi credentials, and generate/download the firmware.
|
||||
4. Use the ESPHome firmware flasher to flash your Tidbyt into a Tronbyt.
|
||||
@@ -49,14 +64,17 @@ Developing additional clients for Tronbyt Server is straightforward: pull WebP i
|
||||
|
||||
**Migration from v1.x:**
|
||||
If you are upgrading from the Python version (v1.x) and using the default SQLite database:
|
||||
1. Ensure your old `usersdb.sqlite` file is in the data directory.
|
||||
1. Ensure your old `usersdb.sqlite` file is in the data directory. If your Python installation had a separate `users` volume/directory (e.g., mounted at `/app/users` in Docker), this `users` directory needs to be attached as a volume to the Go server's main data directory (e.g., `/app/data/users` or directly at `/app/users`) during the first migration run. After successful migration, this old `users` volume can be safely detached and removed, as all its relevant contents will have been migrated to the new `data` structure.
|
||||
2. Start the new server.
|
||||
3. The server will automatically detect the legacy database and migrate your users, devices, and apps to the new `tronbyt.db` format.
|
||||
4. The legacy database will be renamed to `usersdb.sqlite.bak` after successful migration.
|
||||
|
||||
**Development:**
|
||||
* Clone the repository and use `docker compose -f docker-compose.dev.yaml up -d --build` for Docker development.
|
||||
* For native development: `go run ./cmd/server`.
|
||||
* For native development:
|
||||
* Run directly: `go run ./cmd/server`.
|
||||
* With live-reloading (using [Air](https://github.com/air-verse/air)):
|
||||
Run: `PRODUCTION=0 go tool air`
|
||||
|
||||
**Configuration:**
|
||||
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
oldDBPath := flag.String("old", "tronbyt.db", "Path to legacy SQLite database")
|
||||
newDBPath := flag.String("new", "new_tronbyt.db", "Path to new GORM database")
|
||||
oldDBPath := flag.String("old", "users/tronbyt.db", "Path to legacy SQLite database")
|
||||
newDBPath := flag.String("new", "data/tronbyt.db", "Path to new GORM database (or DSN)")
|
||||
dataDir := flag.String("data", "data", "Path to data directory for files")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
if err := migration.MigrateLegacyDB(*oldDBPath, *newDBPath); err != nil {
|
||||
if err := migration.MigrateLegacyDB(*oldDBPath, *newDBPath, *dataDir); err != nil {
|
||||
slog.Error("Database migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,9 @@ func openDB(dsn, logLevel string) (*gorm.DB, error) {
|
||||
slog.Info("Using SQLite DB", "path", dsn)
|
||||
db, err = gorm.Open(sqlite.Open(dsn), gormConfig)
|
||||
if err == nil {
|
||||
db.Exec("PRAGMA journal_mode=WAL;")
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
|
||||
slog.Warn("Failed to set WAL mode for SQLite", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return db, err
|
||||
@@ -172,60 +174,44 @@ func main() {
|
||||
}
|
||||
|
||||
// Check for legacy DB for automatic migration
|
||||
legacyDBName := "usersdb.sqlite" // Old Python DB name
|
||||
legacyDBPath := filepath.Join(*dataDir, legacyDBName)
|
||||
newDBPath := *dbDSN // This is the path for the new Go DB
|
||||
legacyDBPath := filepath.Join("users", "usersdb.sqlite") // Old Python DB path
|
||||
if _, err := os.Stat(legacyDBPath); err == nil && legacyDBPath != *dbDSN {
|
||||
slog.Info("Found legacy database, checking if migration is needed", "legacy_db", legacyDBPath, "new_db", *dbDSN)
|
||||
|
||||
// Only migrate if new DB looks like a file path (for SQLite)
|
||||
// If DSN is postgres/mysql, we assume user handles it or we migrate to it?
|
||||
// migrate logic supports writing to GORM DB, so it supports Postgres/MySQL!
|
||||
// But `legacyDBPath` is file.
|
||||
// Migration logic `MigrateLegacyDB` accepts `oldDBPath` and `newDBPath`.
|
||||
// But `MigrateLegacyDB` internally calls `gorm.Open(sqlite.Open(newDBPath))`.
|
||||
// I need to update `internal/migration/migration.go` to support DSN for new DB!
|
||||
|
||||
// For now, let's assume if it looks like a file, we check it.
|
||||
// If it's a DSN string, os.Stat might fail or succeed randomly?
|
||||
// Actually `MigrateLegacyDB` signature is `(oldPath, newPath string)`.
|
||||
// And it opens new DB as SQLite.
|
||||
// If I want to support migrating FROM sqlite TO postgres, I need to update `migration` package.
|
||||
// Given the scope "Rename dbPath... to reflect available options", I should support it.
|
||||
|
||||
// I'll skip migration logic update for now and focus on main refactor.
|
||||
// But I should use `dbDSN` variable.
|
||||
|
||||
if _, err := os.Stat(legacyDBPath); err == nil && legacyDBPath != newDBPath {
|
||||
slog.Info("Found legacy database, initiating automatic migration", "legacy_db", legacyDBPath, "new_db", newDBPath)
|
||||
|
||||
// Check if new DB exists? For SQLite yes. For Postgres?
|
||||
// We can try to open it and check if users table exists?
|
||||
// For simplicity, skip auto-migration if using non-sqlite for now?
|
||||
// Or assume SQLite for auto-migration path as standard use case.
|
||||
// If user sets up Postgres, they likely know what they are doing and might use `migrate` tool manually (if updated).
|
||||
|
||||
// If newDBPath does NOT look like DSN (no host=, no ://), assume file.
|
||||
isSQLite := !strings.Contains(newDBPath, "host=") && !strings.Contains(newDBPath, "://") && !strings.Contains(newDBPath, "@")
|
||||
|
||||
if isSQLite {
|
||||
if _, err := os.Stat(newDBPath); err == nil {
|
||||
slog.Warn("New database already exists, skipping automatic migration.", "new_db", newDBPath)
|
||||
} else {
|
||||
// Perform migration
|
||||
if err := migration.MigrateLegacyDB(legacyDBPath, newDBPath); err != nil {
|
||||
slog.Error("Automatic migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
skipMigration := false
|
||||
tempDB, err := openDB(*dbDSN, "ERROR")
|
||||
if err == nil {
|
||||
if tempDB.Migrator().HasTable(&data.User{}) {
|
||||
var count int64
|
||||
if err := tempDB.Model(&data.User{}).Count(&count).Error; err == nil && count > 0 {
|
||||
skipMigration = true
|
||||
slog.Warn("New database already has users, skipping automatic migration.", "new_db", *dbDSN)
|
||||
}
|
||||
slog.Info("Automatic migration completed successfully. Renaming legacy DB.", "legacy_db", legacyDBPath)
|
||||
if err := os.Rename(legacyDBPath, legacyDBPath+".bak"); err != nil {
|
||||
slog.Error("Failed to rename legacy DB after migration", "error", err)
|
||||
}
|
||||
if sqlDB, err := tempDB.DB(); err == nil {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
slog.Error("Failed to close temporary DB connection", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !skipMigration {
|
||||
// Perform migration
|
||||
if err := migration.MigrateLegacyDB(legacyDBPath, *dbDSN, *dataDir); err != nil {
|
||||
slog.Error("Automatic migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("Automatic migration completed successfully. Renaming legacy DB.", "legacy_db", legacyDBPath)
|
||||
if err := os.Rename(legacyDBPath, legacyDBPath+".bak"); err != nil {
|
||||
slog.Error("Failed to rename legacy DB after migration", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone/Update System Apps Repo
|
||||
systemAppsDir := filepath.Join(*dataDir, "system-apps")
|
||||
if err := gitutils.CloneOrUpdate(systemAppsDir, cfg.SystemAppsRepo); err != nil {
|
||||
shouldUpdate := cfg.Production == "1"
|
||||
if err := gitutils.EnsureRepo(systemAppsDir, cfg.SystemAppsRepo, shouldUpdate); err != nil {
|
||||
slog.Error("Failed to update system apps repo", "error", err)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ services:
|
||||
- "${SERVER_PORT:-8000}:8000" # Map server port on the host to port 8000 in the container
|
||||
volumes:
|
||||
- ./tronbyt_server:/app/tronbyt_server # for development
|
||||
- ./users:/app/users # for development
|
||||
- ./data:/app/data # for development
|
||||
- "/etc/localtime:/etc/localtime:ro" # used to sync docker with host time
|
||||
environment:
|
||||
|
||||
@@ -5,7 +5,6 @@ services:
|
||||
init: true
|
||||
volumes:
|
||||
- "/etc/localtime:/etc/localtime:ro" # used to sync docker with host time
|
||||
- users:/app/users
|
||||
- data:/app/data
|
||||
environment:
|
||||
- PUID=${UID:-1000}
|
||||
@@ -35,5 +34,4 @@ services:
|
||||
depends_on:
|
||||
- web
|
||||
volumes:
|
||||
users:
|
||||
data:
|
||||
|
||||
@@ -7,7 +7,6 @@ services:
|
||||
init: true
|
||||
volumes:
|
||||
- "/etc/localtime:/etc/localtime:ro" # used to sync docker with host time
|
||||
- users:/app/users
|
||||
- data:/app/data
|
||||
environment:
|
||||
- PUID=${UID:-1000}
|
||||
@@ -38,6 +37,5 @@ services:
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
volumes:
|
||||
users:
|
||||
data:
|
||||
redis:
|
||||
|
||||
@@ -7,7 +7,6 @@ services:
|
||||
init: true
|
||||
volumes:
|
||||
- "/etc/localtime:/etc/localtime:ro" # used to sync docker with host time
|
||||
- users:/app/users
|
||||
- data:/app/data
|
||||
environment:
|
||||
- PUID=${UID:-1000}
|
||||
@@ -25,5 +24,4 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
volumes:
|
||||
users:
|
||||
data:
|
||||
|
||||
16
go.mod
16
go.mod
@@ -29,9 +29,12 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/air-verse/air v1.63.4 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/antchfx/xmlquery v1.5.0 // indirect
|
||||
github.com/antchfx/xpath v1.3.5 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
@@ -40,6 +43,8 @@ require (
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
@@ -50,6 +55,8 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.26 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
@@ -67,10 +74,14 @@ require (
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/nathan-osman/go-sunrise v1.1.0 // indirect
|
||||
github.com/newm4n/go-dfe v0.0.0-20210113055126-9d5f01722db9 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/qri-io/starlib v0.5.1-0.20220611014110-7fb7ff9ec804 // indirect
|
||||
@@ -79,8 +90,11 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||
github.com/tronbyt/gg v0.0.0-20220808163829-95806fa1d427 // indirect
|
||||
github.com/tronbyt/go-libwebp v0.0.0-20251112225040-cf43c5d662de // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
@@ -96,3 +110,5 @@ require (
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
tool github.com/air-verse/air
|
||||
|
||||
156
go.sum
156
go.sum
@@ -4,6 +4,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
@@ -16,6 +18,10 @@ github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/air-verse/air v1.63.4 h1:Z+R4328Bja5QKFMTP0CNeT8aVWdb3D5kbbFvnXnuRhE=
|
||||
github.com/air-verse/air v1.63.4/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
@@ -28,11 +34,43 @@ github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZ
|
||||
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/b5/outline v0.0.0-20210930001007-03f1b39e3ab2/go.mod h1:ml9lPAEMJLY2NqHVyhztZg6ZNvKOgHXSZYMnY1NFSwk=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
|
||||
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
|
||||
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
|
||||
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
|
||||
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
|
||||
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
|
||||
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
|
||||
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
|
||||
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
|
||||
github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
|
||||
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
|
||||
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
|
||||
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
|
||||
github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
|
||||
github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
|
||||
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
|
||||
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
|
||||
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
|
||||
github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
|
||||
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
|
||||
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
|
||||
github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
|
||||
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
|
||||
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
|
||||
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -46,6 +84,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
@@ -65,6 +105,10 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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/dustmop/soup v1.1.2-0.20190516214245-38228baa104e h1:44fmjqDtdCiUNlSjJVp+w1AOs6na3Y6Ai0aIeseFjkI=
|
||||
@@ -79,11 +123,23 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU=
|
||||
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM=
|
||||
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
|
||||
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
@@ -98,6 +154,10 @@ github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lo
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -115,9 +175,29 @@ github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
|
||||
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
|
||||
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
|
||||
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
|
||||
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
|
||||
github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
|
||||
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
|
||||
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
|
||||
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
|
||||
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
@@ -155,6 +235,8 @@ github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w=
|
||||
github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
@@ -165,6 +247,11 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -177,6 +264,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
|
||||
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -184,6 +273,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
@@ -201,17 +292,38 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
|
||||
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nathan-osman/go-sunrise v1.1.0 h1:ZqZmtmtzs8Os/DGQYi0YMHpuUqR/iRoJK+wDO0wTCw8=
|
||||
github.com/nathan-osman/go-sunrise v1.1.0/go.mod h1:RcWqhT+5ShCZDev79GuWLayetpJp78RSjSWxiDowmlM=
|
||||
@@ -222,11 +334,31 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
|
||||
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/paulmach/orb v0.1.5/go.mod h1:pPwxxs3zoAyosNSbNKn1jiXV2+oovRDObDKfTvRegDI=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -270,7 +402,11 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
@@ -289,6 +425,14 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
|
||||
github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
|
||||
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
|
||||
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/tronbyt/gg v0.0.0-20220808163829-95806fa1d427 h1:ZtI+sSJ6at2TpZXcxnUNrHqeTYbQeYmCEn08+J1vs24=
|
||||
github.com/tronbyt/gg v0.0.0-20220808163829-95806fa1d427/go.mod h1:wjdMaUjKINeop89o25+l/doQeDpzXNQdiGpnZxBRRkA=
|
||||
@@ -301,6 +445,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
@@ -308,6 +454,10 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/zachomedia/go-bdf v0.0.0-20220611021443-a3af701111be h1:qf05vm7CJA3tcnR42pv2a/+pvCPGylJcg10B9CRFPvg=
|
||||
github.com/zachomedia/go-bdf v0.0.0-20220611021443-a3af701111be/go.mod h1:FWqHpmEj39kZYjkb4y+GkFRwJofD3lP2k8ataoNlo2Y=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
@@ -398,6 +548,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
@@ -441,6 +592,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -478,6 +631,7 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
@@ -492,3 +646,5 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
@@ -229,8 +229,12 @@ func ListUserApps(dataDir, username string) ([]AppMetadata, error) {
|
||||
IsInstalled: false,
|
||||
}
|
||||
|
||||
// Try to find .star file
|
||||
files, _ := os.ReadDir(appDir)
|
||||
// List contents of this app directory
|
||||
files, err := os.ReadDir(appDir)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to read user app directory", "path", appDir, "error", err)
|
||||
continue // Skip to next app directory
|
||||
}
|
||||
var starFile string
|
||||
for _, f := range files {
|
||||
if filepath.Ext(f.Name()) == ".star" {
|
||||
|
||||
@@ -1,51 +1,123 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
// VerifyPassword checks the password against the hash.
|
||||
// It supports bcrypt (native), scrypt (legacy Werkzeug), and pbkdf2 (legacy Werkzeug).
|
||||
// It supports Argon2id (standard), scrypt (legacy), bcrypt (legacy), and pbkdf2 (legacy).
|
||||
// It returns true if valid, and a bool indicating if the hash is legacy and should be upgraded.
|
||||
func VerifyPassword(hashStr, password string) (bool, bool, error) {
|
||||
slog.Info("VerifyPassword called", "hash_len", len(hashStr))
|
||||
// 1. Check for Bcrypt (starts with $2a$, $2b$, $2y$)
|
||||
|
||||
// 1. Check for Argon2id (Standard)
|
||||
if strings.HasPrefix(hashStr, "$argon2id") {
|
||||
valid, err := verifyArgon2id(hashStr, password)
|
||||
return valid, false, err // Valid and current
|
||||
}
|
||||
|
||||
// 2. Check for Scrypt (Legacy)
|
||||
if strings.HasPrefix(hashStr, "scrypt:") {
|
||||
valid, err := verifyScrypt(hashStr, password)
|
||||
return valid, true, err // Valid but legacy
|
||||
}
|
||||
|
||||
// 3. Check for Bcrypt (Legacy)
|
||||
if strings.HasPrefix(hashStr, "$2") {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashStr), []byte(password))
|
||||
if err == nil {
|
||||
return true, false, nil // Valid and current
|
||||
return true, true, nil // Valid but legacy
|
||||
}
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
// 2. Check for Legacy Werkzeug formats
|
||||
if strings.HasPrefix(hashStr, "scrypt:") {
|
||||
valid, err := verifyScrypt(hashStr, password)
|
||||
return valid, true, err // Valid (if true) but legacy (needs upgrade)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(hashStr, "pbkdf2:sha256:") {
|
||||
valid, err := verifyPbkdf2(hashStr, password)
|
||||
return valid, true, err // Valid (if true) but legacy
|
||||
return valid, true, err // Valid but legacy
|
||||
}
|
||||
|
||||
return false, false, errors.New("unknown hash format")
|
||||
}
|
||||
|
||||
// HashPassword generates a bcrypt hash of the password.
|
||||
// HashPassword generates an Argon2id hash of the password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
const (
|
||||
time = 1
|
||||
memory = 64 * 1024
|
||||
threads = 1
|
||||
keyLen = 32
|
||||
)
|
||||
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen)
|
||||
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, memory, time, threads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
func verifyArgon2id(hashStr, password string) (bool, error) {
|
||||
parts := strings.Split(hashStr, "$")
|
||||
if len(parts) != 6 {
|
||||
return false, errors.New("invalid argon2id format")
|
||||
}
|
||||
|
||||
// parts[0] is empty
|
||||
// parts[1] is "argon2id"
|
||||
// parts[2] is "v=19"
|
||||
// parts[3] is "m=65536,t=1,p=1"
|
||||
// parts[4] is salt (base64)
|
||||
// parts[5] is hash (base64)
|
||||
|
||||
var version int
|
||||
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return false, errors.New("incompatible argon2 version")
|
||||
}
|
||||
|
||||
var memory, time, threads uint32
|
||||
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
keyLen := uint32(len(decodedHash))
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, time, memory, uint8(threads), keyLen)
|
||||
|
||||
return subtle.ConstantTimeCompare(decodedHash, hash) == 1, nil
|
||||
}
|
||||
|
||||
func verifyScrypt(hashStr, password string) (bool, error) {
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/caarlos0/env/v6"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
SecretKey string `env:"SECRET_KEY"`
|
||||
DBDSN string `env:"DB_DSN" envDefault:"tronbyt.db"`
|
||||
DataDir string `env:"DATA_DIR" envDefault:"data"`
|
||||
Production string `env:"PRODUCTION" envDefault:"0"`
|
||||
Production string `env:"PRODUCTION" envDefault:"1"`
|
||||
EnableUserRegistration string `env:"ENABLE_USER_REGISTRATION" envDefault:"1"`
|
||||
MaxUsers int `env:"MAX_USERS" envDefault:"0"`
|
||||
SingleUserAutoLogin string `env:"SINGLE_USER_AUTO_LOGIN" envDefault:"0"`
|
||||
@@ -51,42 +46,5 @@ func LoadSettings() (*Settings, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.SecretKey == "" {
|
||||
secretKey, err := loadOrGenerateSecret(cfg.DataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load or generate secret key: %w", err)
|
||||
}
|
||||
cfg.SecretKey = secretKey
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func loadOrGenerateSecret(dataDir string) (string, error) {
|
||||
keyFile := filepath.Join(dataDir, ".secret_key")
|
||||
// Ensure data directory exists
|
||||
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
slog.Error("Failed to create data directory", "path", dataDir, "error", err)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if content, err := os.ReadFile(keyFile); err == nil {
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
slog.Warn("SECRET_KEY not set, generating random key and saving to file", "path", keyFile)
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
slog.Error("Failed to generate random secret key", "error", err)
|
||||
return "", err // Return error here
|
||||
}
|
||||
secret := base64.StdEncoding.EncodeToString(key)
|
||||
|
||||
if err := os.WriteFile(keyFile, []byte(secret), 0600); err != nil {
|
||||
slog.Error("Failed to write secret key file", "error", err)
|
||||
return "", err // Return error here
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
)
|
||||
|
||||
func TestLoadSettings(t *testing.T) {
|
||||
if err := os.Setenv("SECRET_KEY", "testkey"); err != nil {
|
||||
if err := os.Setenv("DATA_DIR", "testdata"); err != nil {
|
||||
t.Fatalf("Failed to set env: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Unsetenv("SECRET_KEY"); err != nil {
|
||||
if err := os.Unsetenv("DATA_DIR"); err != nil {
|
||||
t.Logf("Failed to unset env: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -20,7 +20,7 @@ func TestLoadSettings(t *testing.T) {
|
||||
t.Fatalf("LoadSettings failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.SecretKey != "testkey" {
|
||||
t.Errorf("Expected SECRET_KEY 'testkey', got '%s'", cfg.SecretKey)
|
||||
if cfg.DataDir != "testdata" {
|
||||
t.Errorf("Expected DATA_DIR 'testdata', got '%s'", cfg.DataDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,34 @@ const (
|
||||
DeviceOther DeviceType = "other"
|
||||
)
|
||||
|
||||
// String returns the human-readable display name for the DeviceType.
|
||||
func (dt DeviceType) String() string {
|
||||
switch dt {
|
||||
case DeviceTidbytGen1:
|
||||
return "Tidbyt Gen1"
|
||||
case DeviceTidbytGen2:
|
||||
return "Tidbyt Gen2"
|
||||
case DevicePixoticker:
|
||||
return "Pixoticker"
|
||||
case DeviceRaspberryPi:
|
||||
return "Raspberry Pi"
|
||||
case DeviceRaspberryPiWide:
|
||||
return "Raspberry Pi Wide"
|
||||
case DeviceTronbytS3:
|
||||
return "Tronbyt S3"
|
||||
case DeviceTronbytS3Wide:
|
||||
return "Tronbyt S3 Wide"
|
||||
case DeviceMatrixPortal:
|
||||
return "MatrixPortal S3"
|
||||
case DeviceMatrixPortalWS:
|
||||
return "MatrixPortal S3 Waveshare"
|
||||
case DeviceOther:
|
||||
return "Other"
|
||||
default:
|
||||
return string(dt) // Fallback to raw string value
|
||||
}
|
||||
}
|
||||
|
||||
type ProtocolType string
|
||||
|
||||
const (
|
||||
@@ -42,6 +70,23 @@ const (
|
||||
ProtocolWS ProtocolType = "WS"
|
||||
)
|
||||
|
||||
func (p ProtocolType) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p ProtocolType) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(p))
|
||||
}
|
||||
|
||||
func (p *ProtocolType) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = ProtocolType(strings.ToUpper(s))
|
||||
return nil
|
||||
}
|
||||
|
||||
type RecurrenceType string
|
||||
|
||||
const (
|
||||
@@ -192,20 +237,21 @@ func ParseCustomBrightnessScale(scaleStr string) map[int]int {
|
||||
type ColorFilter string
|
||||
|
||||
const (
|
||||
ColorFilterNone ColorFilter = "None"
|
||||
ColorFilterDimmed ColorFilter = "Dimmed"
|
||||
ColorFilterRedshift ColorFilter = "Redshift"
|
||||
ColorFilterWarm ColorFilter = "Warm"
|
||||
ColorFilterSunset ColorFilter = "Sunset"
|
||||
ColorFilterSepia ColorFilter = "Sepia"
|
||||
ColorFilterVintage ColorFilter = "Vintage"
|
||||
ColorFilterDusk ColorFilter = "Dusk"
|
||||
ColorFilterCool ColorFilter = "Cool"
|
||||
ColorFilterBlackWhite ColorFilter = "Black & White"
|
||||
ColorFilterIce ColorFilter = "Ice"
|
||||
ColorFilterMoonlight ColorFilter = "Moonlight"
|
||||
ColorFilterNeon ColorFilter = "Neon"
|
||||
ColorFilterPastel ColorFilter = "Pastel"
|
||||
ColorFilterInherit ColorFilter = "inherit"
|
||||
ColorFilterNone ColorFilter = "none"
|
||||
ColorFilterDimmed ColorFilter = "dimmed"
|
||||
ColorFilterRedshift ColorFilter = "redshift"
|
||||
ColorFilterWarm ColorFilter = "warm"
|
||||
ColorFilterSunset ColorFilter = "sunset"
|
||||
ColorFilterSepia ColorFilter = "sepia"
|
||||
ColorFilterVintage ColorFilter = "vintage"
|
||||
ColorFilterDusk ColorFilter = "dusk"
|
||||
ColorFilterCool ColorFilter = "cool"
|
||||
ColorFilterBlackWhite ColorFilter = "bw"
|
||||
ColorFilterIce ColorFilter = "ice"
|
||||
ColorFilterMoonlight ColorFilter = "moonlight"
|
||||
ColorFilterNeon ColorFilter = "neon"
|
||||
ColorFilterPastel ColorFilter = "pastel"
|
||||
)
|
||||
|
||||
// DeviceLocation stores lat/lng and timezone.
|
||||
@@ -239,11 +285,11 @@ func (l *DeviceLocation) Scan(value interface{}) error {
|
||||
|
||||
// DeviceInfo stores firmware and protocol details.
|
||||
type DeviceInfo struct {
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
FirmwareType string `json:"firmware_type"`
|
||||
ProtocolVersion *int `json:"protocol_version"`
|
||||
MACAddress string `json:"mac_address"`
|
||||
ProtocolType string `json:"protocol_type"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
FirmwareType string `json:"firmware_type"`
|
||||
ProtocolVersion *int `json:"protocol_version"`
|
||||
MACAddress string `json:"mac_address"`
|
||||
ProtocolType ProtocolType `json:"protocol_type"`
|
||||
}
|
||||
|
||||
func (i DeviceInfo) Value() (driver.Value, error) {
|
||||
@@ -302,6 +348,7 @@ type User struct {
|
||||
Username string `gorm:"primaryKey"`
|
||||
Password string
|
||||
Email string
|
||||
IsAdmin bool `gorm:"default:false"`
|
||||
APIKey string
|
||||
ThemePreference ThemePreference `gorm:"default:'system'"`
|
||||
SystemRepoURL string
|
||||
@@ -326,8 +373,6 @@ type WebAuthnCredential struct {
|
||||
BackupState bool
|
||||
}
|
||||
|
||||
// GORMSlogLogger wraps slog for GORM logging
|
||||
|
||||
type Device struct {
|
||||
ID string `gorm:"primaryKey"` // 8-char hex
|
||||
Username string `gorm:"index"`
|
||||
@@ -370,8 +415,8 @@ type Device struct {
|
||||
Apps []App `gorm:"foreignKey:DeviceID;references:ID"`
|
||||
}
|
||||
|
||||
func DeviceSupports2x(deviceType DeviceType) bool {
|
||||
switch deviceType {
|
||||
func (dt DeviceType) Supports2x() bool {
|
||||
switch dt {
|
||||
case DeviceRaspberryPiWide, DeviceTronbytS3Wide:
|
||||
return true
|
||||
default:
|
||||
@@ -379,6 +424,16 @@ func DeviceSupports2x(deviceType DeviceType) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Device) GetTimezone() string {
|
||||
if d.Timezone != nil && *d.Timezone != "" {
|
||||
return *d.Timezone
|
||||
}
|
||||
if d.Location.Timezone != "" {
|
||||
return d.Location.Timezone
|
||||
}
|
||||
return "Local"
|
||||
}
|
||||
|
||||
type App struct {
|
||||
// Composite key might be better, but a surrogate ID is easier for GORM
|
||||
ID uint `gorm:"primaryKey"`
|
||||
@@ -393,8 +448,8 @@ type App struct {
|
||||
Enabled bool
|
||||
Pushed bool
|
||||
Order int
|
||||
LastRender int64 // Unix timestamp
|
||||
LastRenderDur int64 // Nanoseconds or milliseconds? Python uses timedelta. Let's store int64 nanoseconds.
|
||||
LastRender time.Time
|
||||
LastRenderDur time.Duration
|
||||
Path *string
|
||||
StartTime *string // HH:MM
|
||||
EndTime *string // HH:MM
|
||||
|
||||
@@ -72,8 +72,8 @@ func GetRepoInfo(path string, remoteURL string) (*RepoInfo, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloneOrUpdate clones a repo if it doesn't exist, or pulls if it does.
|
||||
func CloneOrUpdate(path string, url string) error {
|
||||
// EnsureRepo clones a repo if it doesn't exist, or pulls if it does and update is true.
|
||||
func EnsureRepo(path string, url string, update bool) error {
|
||||
slog.Info("Checking git repo", "path", path, "url", url)
|
||||
|
||||
// Check if path exists
|
||||
@@ -105,10 +105,15 @@ func CloneOrUpdate(path string, url string) error {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return fmt.Errorf("failed to remove old repo: %w", err)
|
||||
}
|
||||
return CloneOrUpdate(path, url)
|
||||
return EnsureRepo(path, url, update)
|
||||
}
|
||||
}
|
||||
|
||||
if !update {
|
||||
slog.Info("Skipping repo update (update=false)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pull
|
||||
w, err := r.Worktree()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,19 +4,26 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/legacy"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MigrateLegacyDB performs the migration from the old SQLite DB to the new GORM DB.
|
||||
func MigrateLegacyDB(oldDBPath, newDBPath string) error {
|
||||
slog.Info("Migrating database", "old", oldDBPath, "new", newDBPath)
|
||||
func MigrateLegacyDB(oldDBPath, newDBLocation, dataDir string) error {
|
||||
slog.Info("Migrating database", "old", oldDBPath, "new", newDBLocation, "dataDir", dataDir)
|
||||
|
||||
// 1. Read Legacy Data
|
||||
users, err := readLegacyUsers(oldDBPath)
|
||||
@@ -26,13 +33,24 @@ func MigrateLegacyDB(oldDBPath, newDBPath string) error {
|
||||
slog.Info("Found users to migrate", "count", len(users))
|
||||
|
||||
// 2. Setup New DB
|
||||
newDB, err := gorm.Open(sqlite.Open(newDBPath), &gorm.Config{})
|
||||
var newDB *gorm.DB
|
||||
if strings.HasPrefix(newDBLocation, "postgres") || strings.Contains(newDBLocation, "host=") {
|
||||
slog.Info("Using Postgres for new DB")
|
||||
newDB, err = gorm.Open(postgres.Open(newDBLocation), &gorm.Config{})
|
||||
} else if strings.Contains(newDBLocation, "@tcp(") || strings.Contains(newDBLocation, "@unix(") {
|
||||
slog.Info("Using MySQL for new DB")
|
||||
newDB, err = gorm.Open(mysql.Open(newDBLocation), &gorm.Config{})
|
||||
} else {
|
||||
slog.Info("Using SQLite for new DB")
|
||||
newDB, err = gorm.Open(sqlite.Open(newDBLocation), &gorm.Config{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open new DB: %w", err)
|
||||
}
|
||||
|
||||
// AutoMigrate schema
|
||||
err = newDB.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{})
|
||||
err = newDB.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate schema: %w", err)
|
||||
}
|
||||
@@ -47,6 +65,11 @@ func MigrateLegacyDB(oldDBPath, newDBPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Migrate Directories
|
||||
if err := migrateDirectories(oldDBPath, dataDir); err != nil {
|
||||
return fmt.Errorf("failed to migrate directories: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Migration complete.")
|
||||
return nil
|
||||
}
|
||||
@@ -99,6 +122,7 @@ func migrateUser(db *gorm.DB, lUser legacy.LegacyUser) error {
|
||||
ThemePreference: data.ThemePreference(lUser.ThemePreference),
|
||||
SystemRepoURL: lUser.SystemRepoURL,
|
||||
AppRepoURL: lUser.AppRepoURL,
|
||||
IsAdmin: lUser.Username == "admin",
|
||||
}
|
||||
|
||||
// Save User first
|
||||
@@ -151,7 +175,7 @@ func mapDevice(username string, lDevice legacy.LegacyDevice) (data.Device, error
|
||||
info.MACAddress = *lDevice.Info.MacAddress
|
||||
}
|
||||
if lDevice.Info.ProtocolType != nil {
|
||||
info.ProtocolType = *lDevice.Info.ProtocolType
|
||||
info.ProtocolType = data.ProtocolType(*lDevice.Info.ProtocolType)
|
||||
}
|
||||
|
||||
// Handle Brightness polymorphism
|
||||
@@ -245,8 +269,8 @@ func mapApp(deviceID string, lApp legacy.LegacyApp) (data.App, error) {
|
||||
Enabled: lApp.Enabled,
|
||||
Pushed: lApp.Pushed,
|
||||
Order: lApp.Order,
|
||||
LastRender: lApp.LastRender,
|
||||
LastRenderDur: legacy.ParseDuration(lApp.LastRenderDuration),
|
||||
LastRender: time.Unix(lApp.LastRender, 0),
|
||||
LastRenderDur: time.Duration(legacy.ParseDuration(lApp.LastRenderDuration)),
|
||||
Path: lApp.Path,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
@@ -266,3 +290,127 @@ func mapApp(deviceID string, lApp legacy.LegacyApp) (data.App, error) {
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func migrateDirectories(oldDBPath, newDataDir string) error {
|
||||
oldDir := filepath.Dir(oldDBPath)
|
||||
|
||||
dbFileName := filepath.Base(oldDBPath)
|
||||
|
||||
// Safety check: ensure old directory is named "users"
|
||||
if filepath.Base(oldDir) != "users" {
|
||||
slog.Warn("Skipping directory migration: old DB directory is not named 'users'", "dir", oldDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
targetUsersDir := filepath.Join(newDataDir, "users")
|
||||
|
||||
if _, err := os.Stat(oldDir); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create target directory
|
||||
if err := os.MkdirAll(targetUsersDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read old users directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == dbFileName {
|
||||
slog.Info("Skipping DB file during directory migration", "file", entry.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
srcPath := filepath.Join(oldDir, entry.Name())
|
||||
dstPath := filepath.Join(targetUsersDir, entry.Name())
|
||||
|
||||
// Check if destination exists
|
||||
if _, err := os.Stat(dstPath); err == nil {
|
||||
slog.Warn("Target already exists, skipping move", "src", srcPath, "dst", dstPath)
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Moving item", "src", srcPath, "dst", dstPath)
|
||||
if err := movePath(srcPath, dstPath); err != nil {
|
||||
return fmt.Errorf("failed to move %s: %w", srcPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func movePath(src, dst string) error {
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
slog.Info("os.Rename failed (likely cross-volume), attempting copy-and-delete", "src", src, "dst", dst, "error", err)
|
||||
if err := copyRecursive(src, dst); err != nil {
|
||||
return fmt.Errorf("copy failed: %w", err)
|
||||
}
|
||||
if err := os.RemoveAll(src); err != nil {
|
||||
return fmt.Errorf("remove source failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyRecursive(src, dst string) error {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return copyDir(src, dst)
|
||||
}
|
||||
return copyFile(src, dst)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) (err error) {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := sourceFile.Close(); closeErr != nil && err == nil {
|
||||
err = fmt.Errorf("failed to close source file %s: %w", src, closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := destFile.Close(); closeErr != nil && err == nil {
|
||||
err = fmt.Errorf("failed to close destination file %s: %w", dst, closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Perform the copy
|
||||
if _, copyErr := io.Copy(destFile, sourceFile); copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
|
||||
return err // This will return nil if no errors, or the first error encountered (open, create, copy, or close)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
if err := copyRecursive(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
602
internal/server/api.go
Normal file
602
internal/server/api.go
Normal file
@@ -0,0 +1,602 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
)
|
||||
|
||||
// --- API Handlers ---
|
||||
|
||||
// DeviceUpdate represents the updatable fields for a device via API.
|
||||
type DeviceUpdate struct {
|
||||
Brightness *int `json:"brightness"`
|
||||
IntervalSec *int `json:"intervalSec"`
|
||||
NightModeEnabled *bool `json:"nightModeEnabled"`
|
||||
NightModeApp *string `json:"nightModeApp"`
|
||||
NightModeBrightness *int `json:"nightModeBrightness"`
|
||||
NightModeStartTime *string `json:"nightModeStartTime"`
|
||||
NightModeEndTime *string `json:"nightModeEndTime"`
|
||||
DimModeStartTime *string `json:"dimModeStartTime"`
|
||||
DimModeBrightness *int `json:"dimModeBrightness"`
|
||||
PinnedApp *string `json:"pinnedApp"`
|
||||
AutoDim *bool `json:"autoDim"` // Legacy
|
||||
}
|
||||
|
||||
// DevicePayload represents the full device data returned via API.
|
||||
type DevicePayload struct {
|
||||
ID string `json:"id"`
|
||||
Type data.DeviceType `json:"type"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Notes string `json:"notes"`
|
||||
IntervalSec int `json:"intervalSec"`
|
||||
Brightness int `json:"brightness"`
|
||||
NightMode NightMode `json:"nightMode"`
|
||||
DimMode DimMode `json:"dimMode"`
|
||||
PinnedApp *string `json:"pinnedApp"`
|
||||
Interstitial Interstitial `json:"interstitial"`
|
||||
LastSeen *string `json:"lastSeen"`
|
||||
Info DeviceInfo `json:"info"`
|
||||
AutoDim bool `json:"autoDim"`
|
||||
}
|
||||
|
||||
// NightMode represents night mode settings in the API payload.
|
||||
type NightMode struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
App string `json:"app"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Brightness int `json:"brightness"`
|
||||
}
|
||||
|
||||
// DimMode represents dim mode settings in the API payload.
|
||||
type DimMode struct {
|
||||
StartTime *string `json:"startTime"`
|
||||
Brightness *int `json:"brightness"`
|
||||
}
|
||||
|
||||
// Interstitial represents interstitial app settings in the API payload.
|
||||
type Interstitial struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
App *string `json:"app"`
|
||||
}
|
||||
|
||||
// DeviceInfo represents device firmware and protocol information in the API payload.
|
||||
type DeviceInfo struct {
|
||||
FirmwareVersion string `json:"firmwareVersion"`
|
||||
FirmwareType string `json:"firmwareType"`
|
||||
ProtocolVersion *int `json:"protocolVersion"`
|
||||
MACAddress string `json:"macAddress"`
|
||||
ProtocolType string `json:"protocolType"`
|
||||
}
|
||||
|
||||
// toDevicePayload converts a data.Device model to a DevicePayload for API responses.
|
||||
func (s *Server) toDevicePayload(d *data.Device) DevicePayload {
|
||||
info := DeviceInfo{
|
||||
FirmwareVersion: d.Info.FirmwareVersion,
|
||||
FirmwareType: d.Info.FirmwareType,
|
||||
ProtocolVersion: d.Info.ProtocolVersion,
|
||||
MACAddress: d.Info.MACAddress,
|
||||
ProtocolType: string(d.Info.ProtocolType),
|
||||
}
|
||||
|
||||
var lastSeen *string
|
||||
if d.LastSeen != nil {
|
||||
iso := d.LastSeen.Format(time.RFC3339)
|
||||
lastSeen = &iso
|
||||
}
|
||||
|
||||
var dimBrightnessPtr *int
|
||||
if d.DimBrightness != nil {
|
||||
val := int(*d.DimBrightness)
|
||||
dimBrightnessPtr = &val
|
||||
}
|
||||
|
||||
return DevicePayload{
|
||||
ID: d.ID,
|
||||
Type: d.Type,
|
||||
DisplayName: d.Name,
|
||||
Notes: d.Notes,
|
||||
IntervalSec: d.DefaultInterval,
|
||||
Brightness: int(d.Brightness),
|
||||
NightMode: NightMode{
|
||||
Enabled: d.NightModeEnabled,
|
||||
App: d.NightModeApp,
|
||||
StartTime: d.NightStart,
|
||||
EndTime: d.NightEnd,
|
||||
Brightness: int(d.NightBrightness),
|
||||
},
|
||||
DimMode: DimMode{
|
||||
StartTime: d.DimTime,
|
||||
Brightness: dimBrightnessPtr,
|
||||
},
|
||||
PinnedApp: d.PinnedApp,
|
||||
Interstitial: Interstitial{
|
||||
Enabled: d.InterstitialEnabled,
|
||||
App: d.InterstitialApp,
|
||||
},
|
||||
LastSeen: lastSeen,
|
||||
Info: info,
|
||||
AutoDim: d.NightModeEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
http.Error(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := UserFromContext(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var device *data.Device
|
||||
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
if d.ID != id {
|
||||
http.Error(w, "Forbidden: Device Key mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
device = d
|
||||
} else {
|
||||
for i := range user.Devices {
|
||||
if user.Devices[i].ID == id {
|
||||
device = &user.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(s.toDevicePayload(device)); err != nil {
|
||||
slog.Error("Failed to encode device JSON", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListInstallations(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
user, _ := UserFromContext(r.Context())
|
||||
var device *data.Device
|
||||
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
if d.ID != id {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
device = d
|
||||
} else {
|
||||
for i := range user.Devices {
|
||||
if user.Devices[i].ID == id {
|
||||
device = &user.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"installations": device.Apps,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
slog.Error("Failed to encode installations JSON", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// PushData represents the data for pushing an image to a device.
|
||||
type PushData struct {
|
||||
InstallationID string `json:"installationID"`
|
||||
InstallationIDAlt string `json:"installationId"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
user, userErr := UserFromContext(r.Context())
|
||||
var device *data.Device
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
if d.ID != id {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
device = d
|
||||
} else if userErr == nil && user != nil {
|
||||
for i := range user.Devices {
|
||||
if user.Devices[i].ID == id {
|
||||
device = &user.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var dataReq PushData
|
||||
if err := json.NewDecoder(r.Body).Decode(&dataReq); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
installID := dataReq.InstallationID
|
||||
if installID == "" {
|
||||
installID = dataReq.InstallationIDAlt
|
||||
}
|
||||
|
||||
imgBytes, err := base64.StdEncoding.DecodeString(dataReq.Image)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid Base64 Image", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.savePushedImage(device.ID, installID, imgBytes); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save image: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if installID != "" {
|
||||
if err := s.ensurePushedApp(device.ID, installID); err != nil {
|
||||
fmt.Printf("Error adding pushed app: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte("WebP received.")); err != nil {
|
||||
slog.Error("Failed to write WebP received message", "error", err)
|
||||
// Non-fatal, response already 200
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) savePushedImage(deviceID, installID string, data []byte) error {
|
||||
dir := filepath.Join(s.DataDir, "webp", deviceID, "pushed")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filename string
|
||||
if installID != "" {
|
||||
filename = installID + ".webp"
|
||||
} else {
|
||||
filename = fmt.Sprintf("__%d.webp", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, filename)
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func (s *Server) ensurePushedApp(deviceID, installID string) error {
|
||||
var count int64
|
||||
err := s.DB.Model(&data.App{}).Where("device_id = ? AND iname = ?", deviceID, installID).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
newApp := data.App{
|
||||
DeviceID: deviceID,
|
||||
Iname: installID,
|
||||
Name: "pushed",
|
||||
UInterval: 10,
|
||||
DisplayTime: 0,
|
||||
Enabled: true,
|
||||
Pushed: true,
|
||||
}
|
||||
|
||||
var maxOrder sql.NullInt64
|
||||
if err := s.DB.Model(&data.App{}).Where("device_id = ?", deviceID).Select("max(`order`)").Row().Scan(&maxOrder); err != nil {
|
||||
slog.Error("Failed to get max app order", "error", err)
|
||||
// Non-fatal, default to 0 for order (if maxOrder.Valid is false, maxOrder.Int64 is 0)
|
||||
}
|
||||
newApp.Order = int(maxOrder.Int64) + 1
|
||||
|
||||
return s.DB.Create(&newApp).Error
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchDevice(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
// Auth handled by middleware, get device
|
||||
var device *data.Device
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
if d.ID != id {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
device = d
|
||||
} else if u, err := UserFromContext(r.Context()); err == nil {
|
||||
for i := range u.Devices {
|
||||
if u.Devices[i].ID == id {
|
||||
device = &u.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var update DeviceUpdate
|
||||
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if update.Brightness != nil {
|
||||
device.Brightness = data.Brightness(*update.Brightness)
|
||||
}
|
||||
if update.IntervalSec != nil {
|
||||
device.DefaultInterval = *update.IntervalSec
|
||||
}
|
||||
if update.NightModeEnabled != nil {
|
||||
device.NightModeEnabled = *update.NightModeEnabled
|
||||
}
|
||||
if update.AutoDim != nil {
|
||||
device.NightModeEnabled = *update.AutoDim
|
||||
}
|
||||
if update.NightModeApp != nil {
|
||||
device.NightModeApp = *update.NightModeApp
|
||||
}
|
||||
if update.NightModeBrightness != nil {
|
||||
device.NightBrightness = data.Brightness(*update.NightModeBrightness)
|
||||
}
|
||||
if update.PinnedApp != nil {
|
||||
if *update.PinnedApp == "" {
|
||||
device.PinnedApp = nil
|
||||
} else {
|
||||
device.PinnedApp = update.PinnedApp
|
||||
}
|
||||
}
|
||||
|
||||
if update.NightModeStartTime != nil {
|
||||
device.NightStart = *update.NightModeStartTime
|
||||
}
|
||||
if update.NightModeEndTime != nil {
|
||||
device.NightEnd = *update.NightModeEndTime
|
||||
}
|
||||
if update.DimModeStartTime != nil {
|
||||
device.DimTime = update.DimModeStartTime
|
||||
}
|
||||
if update.DimModeBrightness != nil {
|
||||
val := data.Brightness(*update.DimModeBrightness)
|
||||
device.DimBrightness = &val
|
||||
}
|
||||
|
||||
if err := s.DB.Save(device).Error; err != nil {
|
||||
http.Error(w, "Failed to update device", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(s.toDevicePayload(device)); err != nil {
|
||||
slog.Error("Failed to encode device", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// InstallationUpdate represents the updatable fields for an app installation via API.
|
||||
type InstallationUpdate struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
RenderIntervalMin *int `json:"renderIntervalMin"`
|
||||
DisplayTimeSec *int `json:"displayTimeSec"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchInstallation(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("id")
|
||||
iname := r.PathValue("iname")
|
||||
|
||||
var device *data.Device
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
if d.ID != deviceID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
device = d
|
||||
} else if u, err := UserFromContext(r.Context()); err == nil {
|
||||
for i := range u.Devices {
|
||||
if u.Devices[i].ID == deviceID {
|
||||
device = &u.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var app *data.App
|
||||
for i := range device.Apps {
|
||||
if device.Apps[i].Iname == iname {
|
||||
app = &device.Apps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if app == nil {
|
||||
http.Error(w, "App not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var update InstallationUpdate
|
||||
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if update.Enabled != nil {
|
||||
app.Enabled = *update.Enabled
|
||||
}
|
||||
if update.RenderIntervalMin != nil {
|
||||
app.UInterval = *update.RenderIntervalMin
|
||||
}
|
||||
if update.DisplayTimeSec != nil {
|
||||
app.DisplayTime = *update.DisplayTimeSec
|
||||
}
|
||||
if update.Pinned != nil {
|
||||
if *update.Pinned {
|
||||
device.PinnedApp = &app.Iname
|
||||
} else if device.PinnedApp != nil && *device.PinnedApp == app.Iname {
|
||||
device.PinnedApp = nil
|
||||
}
|
||||
// Save device for pinned change
|
||||
s.DB.Save(device)
|
||||
}
|
||||
|
||||
if err := s.DB.Save(app).Error; err != nil {
|
||||
http.Error(w, "Failed to update app", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(app); err != nil {
|
||||
slog.Error("Failed to encode app", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteInstallationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("id")
|
||||
iname := r.PathValue("iname")
|
||||
|
||||
var device *data.Device
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
if d.ID != deviceID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
device = d
|
||||
} else if u, err := UserFromContext(r.Context()); err == nil {
|
||||
for i := range u.Devices {
|
||||
if u.Devices[i].ID == deviceID {
|
||||
device = &u.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.Where("device_id = ? AND iname = ?", device.ID, iname).Delete(&data.App{}).Error; err != nil {
|
||||
http.Error(w, "Failed to delete app", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up files (install dir and webp)
|
||||
installDir := filepath.Join(s.DataDir, "installations", iname)
|
||||
if err := os.RemoveAll(installDir); err != nil {
|
||||
slog.Error("Failed to remove install directory", "path", installDir, "error", err)
|
||||
}
|
||||
|
||||
webpDir := s.getDeviceWebPDir(device.ID)
|
||||
matches, _ := filepath.Glob(filepath.Join(webpDir, fmt.Sprintf("*-%s.webp", iname)))
|
||||
for _, match := range matches {
|
||||
if err := os.Remove(match); err != nil {
|
||||
slog.Error("Failed to remove webp file", "path", match, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte("App deleted.")); err != nil {
|
||||
slog.Error("Failed to write response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDots(w http.ResponseWriter, r *http.Request) {
|
||||
widthStr := r.URL.Query().Get("w")
|
||||
heightStr := r.URL.Query().Get("h")
|
||||
radiusStr := r.URL.Query().Get("r")
|
||||
|
||||
width := 64
|
||||
height := 32
|
||||
radius := 0.3
|
||||
|
||||
if wVal, err := strconv.Atoi(widthStr); err == nil && wVal > 0 {
|
||||
width = wVal
|
||||
}
|
||||
if hVal, err := strconv.Atoi(heightStr); err == nil && hVal > 0 {
|
||||
height = hVal
|
||||
}
|
||||
if rVal, err := strconv.ParseFloat(radiusStr, 64); err == nil && rVal > 0 {
|
||||
radius = rVal
|
||||
}
|
||||
|
||||
etag := fmt.Sprintf("\"%d-%d-%f\"", width, height, radius)
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||
sb.WriteString(fmt.Sprintf("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" fill=\"#fff\">\n", width, height))
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
sb.WriteString(fmt.Sprintf("<circle cx=\"%f\" cy=\"%f\" r=\"%f\"/>", float64(x)+0.5, float64(y)+0.5, radius))
|
||||
}
|
||||
}
|
||||
sb.WriteString("</svg>\n")
|
||||
|
||||
if _, err := w.Write([]byte(sb.String())); err != nil {
|
||||
slog.Error("Failed to write dots SVG", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get device webp directory (from server.go)
|
||||
func (s *Server) getDeviceWebPDir(deviceID string) string {
|
||||
path := filepath.Join(s.DataDir, "webp", deviceID)
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
slog.Error("Failed to create device webp directory", "path", path, "error", err)
|
||||
// Non-fatal, continue.
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (s *Server) SetupAPIRoutes() {
|
||||
// API v0 Group - authenticated with Middleware
|
||||
s.Router.Handle("GET /v0/devices/{id}", s.APIAuthMiddleware(http.HandlerFunc(s.handleGetDevice)))
|
||||
s.Router.Handle("POST /v0/devices/{id}/push", s.APIAuthMiddleware(http.HandlerFunc(s.handlePush)))
|
||||
s.Router.Handle("GET /v0/devices/{id}/installations", s.APIAuthMiddleware(http.HandlerFunc(s.handleListInstallations)))
|
||||
s.Router.Handle("PATCH /v0/devices/{id}", s.APIAuthMiddleware(http.HandlerFunc(s.handlePatchDevice)))
|
||||
s.Router.Handle("PATCH /v0/devices/{id}/installations/{iname}", s.APIAuthMiddleware(http.HandlerFunc(s.handlePatchInstallation)))
|
||||
s.Router.Handle("DELETE /v0/devices/{id}/installations/{iname}", s.APIAuthMiddleware(http.HandlerFunc(s.handleDeleteInstallationAPI)))
|
||||
|
||||
s.Router.HandleFunc("GET /dots", s.handleDots)
|
||||
}
|
||||
349
internal/server/api_test.go
Normal file
349
internal/server/api_test.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"tronbyt-server/internal/config"
|
||||
"tronbyt-server/internal/data"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestServerAPI(t *testing.T) *Server {
|
||||
dbName := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open DB: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil {
|
||||
t.Fatalf("Failed to migrate DB: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed settings to avoid "record not found" logs during NewServer
|
||||
db.Create(&data.Setting{Key: "secret_key", Value: "testsecret"})
|
||||
db.Create(&data.Setting{Key: "system_apps_repo", Value: ""})
|
||||
|
||||
cfg := &config.Settings{}
|
||||
|
||||
s := NewServer(db, cfg)
|
||||
s.DataDir = t.TempDir()
|
||||
|
||||
// Setup common test user and device
|
||||
adminUser := data.User{
|
||||
Username: "admin",
|
||||
Password: "$2a$10$w3bQ0wWwWwWwWwWwWwWwWu.D/ZJ.p.Xg.3Q.Q.Q.Q.Q.Q.Q.Q", // Placeholder for hashed password
|
||||
APIKey: "admin_test_api_key",
|
||||
}
|
||||
if err := db.Create(&adminUser).Error; err != nil {
|
||||
t.Fatalf("Failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
user := data.User{
|
||||
Username: "testuser",
|
||||
Password: "$2a$10$w3bQ0wWwWwWwWwWwWwWwWu.D/ZJ.p.Xg.3Q.Q.Q.Q.Q.Q.Q.Q", // Placeholder for hashed password
|
||||
APIKey: "test_api_key",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("Failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
device := data.Device{
|
||||
ID: "testdevice",
|
||||
Username: "testuser",
|
||||
Name: "Test Device",
|
||||
APIKey: "device_api_key",
|
||||
}
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatalf("Failed to create test device: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Helper to create a request with API key
|
||||
func newAPIRequest(method, path, apiKey string, body []byte) *http.Request {
|
||||
req := httptest.NewRequest(method, path, bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
return req
|
||||
}
|
||||
|
||||
func TestHandleDots(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/dots?w=2&h=1&r=0.75", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if rr.Header().Get("Content-Type") != "image/svg+xml" {
|
||||
t.Errorf("handler returned wrong content type: got %v want %v",
|
||||
rr.Header().Get("Content-Type"), "image/svg+xml")
|
||||
}
|
||||
|
||||
expectedSVG := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2" height="1" fill="#fff">
|
||||
<circle cx="0.500000" cy="0.500000" r="0.750000"/><circle cx="1.500000" cy="0.500000" r="0.750000"/></svg>
|
||||
`
|
||||
|
||||
if rr.Body.String() != expectedSVG {
|
||||
t.Errorf("handler returned unexpected body: got %v want %v",
|
||||
rr.Body.String(), expectedSVG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetDevice(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
|
||||
req := newAPIRequest("GET", fmt.Sprintf("/v0/devices/%s", deviceID), apiKey, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var payload DevicePayload
|
||||
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if payload.ID != deviceID {
|
||||
t.Errorf("Expected device ID %s, got %s", deviceID, payload.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePush(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "device_api_key"
|
||||
deviceID := "testdevice"
|
||||
installID := "testapp"
|
||||
|
||||
// Create a dummy WebP image (a very small, valid WebP header + some data)
|
||||
dummyWebp := "UklGRkXlAAAgAAAAAQABAAHAIwAA//VucG/v/4/f//x8oAA=" // Base64 encoded 1x1 green webp
|
||||
|
||||
pushData := PushData{
|
||||
InstallationID: installID,
|
||||
Image: dummyWebp,
|
||||
}
|
||||
body, _ := json.Marshal(pushData)
|
||||
|
||||
req := newAPIRequest("POST", fmt.Sprintf("/v0/devices/%s/push", deviceID), apiKey, body)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// Verify the app was created and image saved
|
||||
var app data.App
|
||||
if err := s.DB.Where("device_id = ? AND iname = ?", deviceID, installID).First(&app).Error; err != nil {
|
||||
t.Fatalf("Expected app to be created, but got error: %v", err)
|
||||
}
|
||||
|
||||
if !app.Pushed {
|
||||
t.Error("Expected app to be marked as pushed")
|
||||
}
|
||||
|
||||
// Verify image file exists
|
||||
expectedPath := filepath.Join(s.DataDir, "webp", deviceID, "pushed", installID+".webp")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected pushed image to exist at %s, but it didn't", expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListInstallations(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
|
||||
// Add a dummy app to the device
|
||||
dummyApp := data.App{
|
||||
DeviceID: deviceID,
|
||||
Iname: "dummyapp",
|
||||
Name: "Dummy App",
|
||||
UInterval: 10,
|
||||
DisplayTime: 10,
|
||||
Enabled: true,
|
||||
Order: 0,
|
||||
}
|
||||
if err := s.DB.Create(&dummyApp).Error; err != nil {
|
||||
t.Fatalf("Failed to create dummy app: %v", err)
|
||||
}
|
||||
|
||||
req := newAPIRequest("GET", fmt.Sprintf("/v0/devices/%s/installations", deviceID), apiKey, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Installations []data.App `json:"installations"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(response.Installations) != 1 || response.Installations[0].Iname != "dummyapp" {
|
||||
t.Errorf("Expected 1 installation with iname 'dummyapp', got %v", response.Installations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchDevice(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
|
||||
// Initial device state
|
||||
var device data.Device
|
||||
s.DB.First(&device, "id = ?", deviceID)
|
||||
originalBrightness := device.Brightness
|
||||
|
||||
// Patch brightness
|
||||
newBrightness := 50
|
||||
update := DeviceUpdate{Brightness: &newBrightness}
|
||||
body, _ := json.Marshal(update)
|
||||
req := newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s", deviceID), apiKey, body)
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v: %s",
|
||||
rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify updated device state
|
||||
s.DB.First(&device, "id = ?", deviceID)
|
||||
if device.Brightness != data.Brightness(newBrightness) {
|
||||
t.Errorf("Expected brightness %d, got %d", newBrightness, device.Brightness)
|
||||
}
|
||||
|
||||
// Patch interval
|
||||
newInterval := 30
|
||||
update = DeviceUpdate{IntervalSec: &newInterval}
|
||||
body, _ = json.Marshal(update)
|
||||
req = newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s", deviceID), apiKey, body)
|
||||
rr = httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v: %s",
|
||||
rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify updated device state
|
||||
s.DB.First(&device, "id = ?", deviceID)
|
||||
if device.DefaultInterval != newInterval {
|
||||
t.Errorf("Expected interval %d, got %d", newInterval, device.DefaultInterval)
|
||||
}
|
||||
|
||||
// Restore original brightness to avoid affecting other tests that might rely on it
|
||||
update = DeviceUpdate{Brightness: (*int)(&originalBrightness)}
|
||||
body, _ = json.Marshal(update)
|
||||
req = newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s", deviceID), apiKey, body)
|
||||
rr = httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
}
|
||||
|
||||
func TestHandlePatchInstallation(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
installID := "patchapp"
|
||||
|
||||
// Add a dummy app to the device
|
||||
app := data.App{
|
||||
DeviceID: deviceID,
|
||||
Iname: installID,
|
||||
Name: "Patch App",
|
||||
UInterval: 10,
|
||||
DisplayTime: 10,
|
||||
Enabled: true,
|
||||
Order: 0,
|
||||
}
|
||||
if err := s.DB.Create(&app).Error; err != nil {
|
||||
t.Fatalf("Failed to create dummy app: %v", err)
|
||||
}
|
||||
|
||||
// Patch enabled status
|
||||
newEnabled := false
|
||||
update := InstallationUpdate{Enabled: &newEnabled}
|
||||
body, _ := json.Marshal(update)
|
||||
req := newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s/installations/%s", deviceID, installID), apiKey, body)
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v: %s",
|
||||
rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify updated app state
|
||||
s.DB.First(&app, "iname = ?", installID)
|
||||
if app.Enabled != newEnabled {
|
||||
t.Errorf("Expected app enabled to be %t, got %t", newEnabled, app.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteInstallationAPI(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
installID := "deleteapp"
|
||||
|
||||
// Add a dummy app to the device
|
||||
app := data.App{
|
||||
DeviceID: deviceID,
|
||||
Iname: installID,
|
||||
Name: "Delete App",
|
||||
UInterval: 10,
|
||||
DisplayTime: 10,
|
||||
Enabled: true,
|
||||
Order: 0,
|
||||
Path: nil, // Important for cleanup not to try to delete a non-existent file
|
||||
}
|
||||
if err := s.DB.Create(&app).Error; err != nil {
|
||||
t.Fatalf("Failed to create dummy app: %v", err)
|
||||
}
|
||||
|
||||
req := newAPIRequest("DELETE", fmt.Sprintf("/v0/devices/%s/installations/%s", deviceID, installID), apiKey, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// Verify app is deleted
|
||||
var deletedApp data.App
|
||||
if err := s.DB.Where("device_id = ? AND iname = ?", deviceID, installID).First(&deletedApp).Error; err == nil {
|
||||
t.Errorf("App was not deleted")
|
||||
}
|
||||
}
|
||||
426
internal/server/auth.go
Normal file
426
internal/server/auth.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"log/slog"
|
||||
"tronbyt-server/internal/auth"
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/gitutils"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
)
|
||||
|
||||
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Debug("handleLoginGet called")
|
||||
|
||||
// Check session
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
if username, ok := session.Values["username"].(string); ok {
|
||||
// Validate user exists in DB
|
||||
var user data.User
|
||||
if err := s.DB.First(&user, "username = ?", username).Error; err == nil {
|
||||
// User exists, redirect to home
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
} else {
|
||||
// User not found in DB, invalidate session
|
||||
slog.Info("User in session not found in DB, invalidating session", "username", username)
|
||||
session.Options.MaxAge = -1 // Expire cookie
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session after invalidation", "error", err)
|
||||
}
|
||||
// Fall through to login logic
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-Login Check
|
||||
if s.Config.SingleUserAutoLogin == "1" {
|
||||
var count int64
|
||||
if err := s.DB.Model(&data.User{}).Count(&count).Error; err == nil && count == 1 {
|
||||
if s.isTrustedNetwork(r) {
|
||||
var user data.User
|
||||
s.DB.First(&user)
|
||||
session.Values["username"] = user.Username
|
||||
session.Options.MaxAge = 86400 * 30
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session for auto-login", "error", err)
|
||||
}
|
||||
slog.Info("Auto-logged in single user from trusted network", "username", user.Username, "ip", s.getRealIP(r))
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := s.DB.Model(&data.User{}).Count(&count).Error; err != nil {
|
||||
slog.Error("Failed to count users", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
slog.Info("No users found, redirecting to registration for owner setup")
|
||||
http.Redirect(w, r, "/auth/register", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.renderTemplate(w, r, "login", TemplateData{})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Debug("handleLoginPost called")
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
var user data.User
|
||||
if err := s.DB.First(&user, "username = ?", username).Error; err != nil {
|
||||
slog.Warn("Login failed: user not found", "username", username)
|
||||
localizer := s.getLocalizer(r)
|
||||
s.renderTemplate(w, r, "login", TemplateData{Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Invalid username or password"})}})
|
||||
return
|
||||
}
|
||||
|
||||
valid, legacy, err := auth.VerifyPassword(user.Password, password)
|
||||
if err != nil {
|
||||
slog.Error("Password check error", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
slog.Warn("Login failed: invalid password", "username", username)
|
||||
localizer := s.getLocalizer(r)
|
||||
s.renderTemplate(w, r, "login", TemplateData{Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Invalid username or password"})}})
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade password if legacy
|
||||
if legacy {
|
||||
slog.Info("Upgrading password hash", "username", username)
|
||||
newHash, err := auth.HashPassword(password)
|
||||
if err == nil {
|
||||
s.DB.Model(&user).Update("password", newHash)
|
||||
} else {
|
||||
slog.Error("Failed to upgrade password hash", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Login successful
|
||||
slog.Info("Login successful", "username", username)
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
session.Values["username"] = user.Username
|
||||
|
||||
if r.FormValue("remember") == "on" {
|
||||
session.Options.MaxAge = 86400 * 30
|
||||
} else {
|
||||
session.Options.MaxAge = 0
|
||||
}
|
||||
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
delete(session.Values, "username")
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session on logout", "error", err)
|
||||
// Non-fatal, redirect anyway
|
||||
}
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRegisterGet(w http.ResponseWriter, r *http.Request) {
|
||||
var count int64
|
||||
s.DB.Model(&data.User{}).Count(&count)
|
||||
|
||||
if s.Config.EnableUserRegistration != "1" && count > 0 {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
currentUsername, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var user data.User
|
||||
if err := s.DB.First(&user, "username = ?", currentUsername).Error; err != nil || !user.IsAdmin {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var flashes []string
|
||||
if count == 0 {
|
||||
localizer := s.getLocalizer(r)
|
||||
flashes = append(flashes, localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "System Setup: Please create the 'admin' user."}))
|
||||
}
|
||||
|
||||
s.renderTemplate(w, r, "register", TemplateData{Flashes: flashes, UserCount: int(count)})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
email := r.FormValue("email")
|
||||
|
||||
var count int64
|
||||
s.DB.Model(&data.User{}).Count(&count)
|
||||
|
||||
localizer := s.getLocalizer(r)
|
||||
|
||||
if s.Config.EnableUserRegistration != "1" && count > 0 {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
currentUsername, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var currentUser data.User
|
||||
if err := s.DB.First(¤tUser, "username = ?", currentUsername).Error; err != nil || !currentUser.IsAdmin {
|
||||
http.Error(w, localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "User registration is not enabled."}), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
s.renderTemplate(w, r, "register", TemplateData{Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Username and password required"})}})
|
||||
return
|
||||
}
|
||||
|
||||
var existing data.User
|
||||
if err := s.DB.First(&existing, "username = ?", username).Error; err == nil {
|
||||
s.renderTemplate(w, r, "register", TemplateData{Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Username already exists"})}})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
slog.Error("Failed to hash password", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if requester is admin
|
||||
var requesterIsAdmin bool
|
||||
if count == 0 {
|
||||
requesterIsAdmin = true
|
||||
} else {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
if currentUsername, ok := session.Values["username"].(string); ok {
|
||||
var currentUser data.User
|
||||
if err := s.DB.First(¤tUser, "username = ?", currentUsername).Error; err == nil {
|
||||
requesterIsAdmin = currentUser.IsAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if count == 0 {
|
||||
isAdmin = true
|
||||
} else if requesterIsAdmin {
|
||||
isAdmin = r.FormValue("is_admin") == "on"
|
||||
}
|
||||
|
||||
apiKey, _ := generateSecureToken(32)
|
||||
|
||||
newUser := data.User{
|
||||
Username: username,
|
||||
Password: hashedPassword,
|
||||
Email: email,
|
||||
APIKey: apiKey,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
if err := s.DB.Create(&newUser).Error; err != nil {
|
||||
slog.Error("Failed to create user", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-login the first created user
|
||||
if count == 0 {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
session.Values["username"] = newUser.Username
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session for auto-login", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleEditUserGet(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var user data.User
|
||||
if err := s.DB.Preload("Credentials").First(&user, "username = ?", username).Error; err != nil {
|
||||
slog.Error("Failed to fetch user for edit", "username", username, "error", err)
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Get System Repo Info if admin (Stub for now or implement) // Python: system_apps.get_system_repo_info
|
||||
// I'll leave it empty for now or implement later if critical.
|
||||
// Template expects 'system_repo_info' but I don't pass it in TemplateData explicitly,
|
||||
// unless I extend TemplateData or pass map.
|
||||
// Go TemplateData has User.
|
||||
// I need to add fields to TemplateData if I want to pass extra info.
|
||||
// 'FirmwareVersion' is there.
|
||||
|
||||
firmwareVersion := "unknown"
|
||||
firmwareFile := filepath.Join(s.DataDir, "firmware", "firmware_version.txt")
|
||||
if bytes, err := os.ReadFile(firmwareFile); err == nil {
|
||||
firmwareVersion = strings.TrimSpace(string(bytes))
|
||||
}
|
||||
|
||||
var systemRepoInfo *gitutils.RepoInfo
|
||||
if s.Config.SystemAppsRepo != "" {
|
||||
path := filepath.Join(s.DataDir, "system-apps")
|
||||
info, err := gitutils.GetRepoInfo(path, s.Config.SystemAppsRepo)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get system repo info", "error", err)
|
||||
} else {
|
||||
systemRepoInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
var userRepoInfo *gitutils.RepoInfo
|
||||
if user.AppRepoURL != "" {
|
||||
path := filepath.Join(s.DataDir, "users", user.Username, "apps")
|
||||
info, err := gitutils.GetRepoInfo(path, user.AppRepoURL)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get user repo info", "error", err)
|
||||
} else {
|
||||
userRepoInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
s.renderTemplate(w, r, "edit", TemplateData{
|
||||
User: &user,
|
||||
FirmwareVersion: firmwareVersion,
|
||||
SystemRepoInfo: systemRepoInfo,
|
||||
UserRepoInfo: userRepoInfo,
|
||||
GlobalSystemRepoURL: s.Config.SystemAppsRepo,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleEditUserPost(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var user data.User
|
||||
if err := s.DB.First(&user, "username = ?", username).Error; err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
oldPassword := r.FormValue("old_password")
|
||||
newPassword := r.FormValue("password")
|
||||
|
||||
if oldPassword != "" && newPassword != "" {
|
||||
valid, _, err := auth.VerifyPassword(user.Password, oldPassword)
|
||||
if err != nil || !valid {
|
||||
localizer := s.getLocalizer(r)
|
||||
s.renderTemplate(w, r, "edit", TemplateData{User: &user, Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Invalid old password"})}})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.Password = hash
|
||||
if err := s.DB.Save(&user).Error; err != nil {
|
||||
slog.Error("Failed to update password", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Flash success?
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleGenerateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := generateSecureToken(32)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.Model(&data.User{}).Where("username = ?", username).Update("api_key", apiKey).Error; err != nil {
|
||||
slog.Error("Failed to update API key", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := r.FormValue("api_key")
|
||||
if apiKey == "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther) // Should redirect to edit page?
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.Model(&data.User{}).Where("username = ?", username).Update("api_key", apiKey).Error; err != nil {
|
||||
slog.Error("Failed to update API key", "error", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) SetupAuthRoutes() {
|
||||
s.Router.HandleFunc("GET /auth/login", s.handleLoginGet)
|
||||
s.Router.HandleFunc("POST /auth/login", s.handleLoginPost)
|
||||
s.Router.HandleFunc("GET /auth/logout", s.handleLogout)
|
||||
s.Router.HandleFunc("GET /auth/register", s.handleRegisterGet)
|
||||
s.Router.HandleFunc("POST /auth/register", s.handleRegisterPost)
|
||||
s.Router.HandleFunc("GET /auth/edit", s.handleEditUserGet)
|
||||
s.Router.HandleFunc("POST /auth/edit", s.handleEditUserPost)
|
||||
s.Router.HandleFunc("POST /auth/generate_api_key", s.handleGenerateAPIKey)
|
||||
s.Router.HandleFunc("POST /auth/set_api_key", s.handleSetAPIKey)
|
||||
|
||||
// WebAuthn
|
||||
s.Router.HandleFunc("GET /auth/webauthn/register/begin", s.handleWebAuthnRegisterBegin)
|
||||
s.Router.HandleFunc("POST /auth/webauthn/register/finish", s.handleWebAuthnRegisterFinish)
|
||||
s.Router.HandleFunc("GET /auth/webauthn/login/begin", s.handleWebAuthnLoginBegin)
|
||||
s.Router.HandleFunc("POST /auth/webauthn/login/finish", s.handleWebAuthnLoginFinish)
|
||||
s.Router.HandleFunc("POST /auth/webauthn/delete/{id}", s.handleDeleteWebAuthnCredential)
|
||||
}
|
||||
158
internal/server/device_api.go
Normal file
158
internal/server/device_api.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// handleNextApp is the handler for GET /{id}/next
|
||||
func (s *Server) handleNextApp(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
var device *data.Device
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
device = d
|
||||
} else if u, err := UserFromContext(r.Context()); err == nil {
|
||||
for i := range u.Devices {
|
||||
if u.Devices[i].ID == id {
|
||||
device = &u.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: Fetch from DB directly (No Auth required for device operation)
|
||||
var d data.Device
|
||||
if err := s.DB.Preload("Apps").First(&d, "id = ?", id).Error; err == nil {
|
||||
device = &d
|
||||
}
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if len(device.Apps) == 0 {
|
||||
var reloaded data.Device
|
||||
if err := s.DB.Preload("Apps").First(&reloaded, "id = ?", device.ID).Error; err == nil {
|
||||
device = &reloaded
|
||||
}
|
||||
}
|
||||
|
||||
user, _ := UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
var owner data.User
|
||||
s.DB.First(&owner, "username = ?", device.Username)
|
||||
user = &owner
|
||||
}
|
||||
|
||||
imgData, app, err := s.GetNextAppImage(r.Context(), device, user)
|
||||
if err != nil {
|
||||
// Send default image if error (or not found)
|
||||
s.sendDefaultImage(w, r, device)
|
||||
return
|
||||
}
|
||||
|
||||
// Send Headers
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
|
||||
// Determine Brightness
|
||||
brightness := device.Brightness
|
||||
if GetNightModeIsActive(device) {
|
||||
brightness = device.NightBrightness
|
||||
} else if GetDimModeIsActive(device) && device.DimBrightness != nil {
|
||||
brightness = *device.DimBrightness
|
||||
}
|
||||
w.Header().Set("Tronbyt-Brightness", fmt.Sprintf("%d", brightness))
|
||||
|
||||
dwell := device.DefaultInterval
|
||||
if app != nil && app.DisplayTime > 0 {
|
||||
dwell = app.DisplayTime
|
||||
}
|
||||
w.Header().Set("Tronbyt-Dwell-Secs", fmt.Sprintf("%d", dwell))
|
||||
|
||||
if _, err := w.Write(imgData); err != nil {
|
||||
slog.Error("Failed to write image data to response", "error", err)
|
||||
// Log error, but can't change HTTP status after writing headers.
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("id")
|
||||
|
||||
var device data.Device
|
||||
if err := s.DB.Preload("Apps").First(&device, "id = ?", deviceID).Error; err != nil {
|
||||
slog.Warn("WS connection rejected: device not found", "id", deviceID)
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var user data.User
|
||||
s.DB.First(&user, "username = ?", device.Username)
|
||||
|
||||
conn, err := s.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
slog.Error("WS upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
slog.Error("Failed to close WS connection", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("WS Connected", "device", deviceID)
|
||||
|
||||
// Update protocol type
|
||||
s.DB.Model(&device).Update("info", data.JSONMap{"protocol_type": data.ProtocolWS})
|
||||
|
||||
ch := s.Broadcaster.Subscribe(deviceID)
|
||||
defer s.Broadcaster.Unsubscribe(deviceID, ch)
|
||||
|
||||
ackCh := make(chan WSMessage, 10)
|
||||
stopCh := make(chan struct{})
|
||||
|
||||
// Read loop to handle ping/pong/close and client messages
|
||||
go func() {
|
||||
defer close(stopCh)
|
||||
for {
|
||||
var msg WSMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
slog.Info("WS closed unexpectedly", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Message
|
||||
if msg.ClientInfo != nil {
|
||||
// Update Device Info
|
||||
device.Info.FirmwareVersion = msg.ClientInfo.FirmwareVersion
|
||||
device.Info.FirmwareType = msg.ClientInfo.FirmwareType
|
||||
if msg.ClientInfo.ProtocolVersion != nil {
|
||||
device.Info.ProtocolVersion = msg.ClientInfo.ProtocolVersion
|
||||
}
|
||||
device.Info.MACAddress = msg.ClientInfo.MACAddress
|
||||
|
||||
if err := s.DB.Model(&device).Update("info", device.Info).Error; err != nil {
|
||||
slog.Error("Failed to update device info", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Queued != nil || msg.Displaying != nil {
|
||||
select {
|
||||
case ackCh <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
s.wsWriteLoop(r.Context(), conn, &device, &user, ackCh, ch, stopCh)
|
||||
}
|
||||
47
internal/server/device_api_test.go
Normal file
47
internal/server/device_api_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
)
|
||||
|
||||
func TestHandleNextApp(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
var device data.Device
|
||||
s.DB.First(&device, "id = ?", "testdevice")
|
||||
|
||||
app := data.App{
|
||||
DeviceID: "testdevice",
|
||||
Iname: "testapp",
|
||||
Name: "Test App",
|
||||
UInterval: 10,
|
||||
Enabled: true,
|
||||
Pushed: true,
|
||||
}
|
||||
if err := s.DB.Create(&app).Error; err != nil {
|
||||
t.Fatalf("Failed to create app: %v", err)
|
||||
}
|
||||
|
||||
if err := s.savePushedImage("testdevice", "testapp", []byte("dummy image")); err != nil {
|
||||
t.Fatalf("Failed to save pushed image: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/testdevice/next", nil)
|
||||
req.SetPathValue("id", "testdevice")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.handleNextApp(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if rr.Header().Get("Content-Type") != "image/webp" {
|
||||
t.Errorf("Expected content type image/webp, got %s", rr.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/firmware"
|
||||
|
||||
"log/slog"
|
||||
@@ -154,31 +153,8 @@ func (s *Server) UpdateFirmwareBinaries() error {
|
||||
}
|
||||
|
||||
func (s *Server) handleFirmwareGenerateGet(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var user data.User
|
||||
if err := s.DB.Preload("Devices").First(&user, "username = ?", username).Error; err != nil {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var device *data.Device
|
||||
for i := range user.Devices {
|
||||
if user.Devices[i].ID == id {
|
||||
device = &user.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
user := GetUser(r)
|
||||
device := GetDevice(r)
|
||||
|
||||
// Check firmware availability
|
||||
firmwareDir := filepath.Join(s.DataDir, "firmware")
|
||||
@@ -204,7 +180,7 @@ func (s *Server) handleFirmwareGenerateGet(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
localizer := s.getLocalizer(r)
|
||||
s.renderTemplate(w, r, "firmware", TemplateData{
|
||||
User: &user,
|
||||
User: user,
|
||||
Device: device,
|
||||
FirmwareBinsAvailable: binsAvailable,
|
||||
FirmwareVersion: version,
|
||||
@@ -214,31 +190,7 @@ func (s *Server) handleFirmwareGenerateGet(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (s *Server) handleFirmwareGeneratePost(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var user data.User
|
||||
if err := s.DB.Preload("Devices").First(&user, "username = ?", username).Error; err != nil {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var device *data.Device
|
||||
for i := range user.Devices {
|
||||
if user.Devices[i].ID == id {
|
||||
device = &user.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
device := GetDevice(r)
|
||||
|
||||
ssid := r.FormValue("wifi_ap")
|
||||
password := r.FormValue("wifi_password")
|
||||
|
||||
95
internal/server/firmware_test.go
Normal file
95
internal/server/firmware_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/firmware"
|
||||
)
|
||||
|
||||
func TestHandleFirmwareGenerateGet(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
|
||||
var user data.User
|
||||
s.DB.First(&user, "username = ?", "testuser")
|
||||
var device data.Device
|
||||
s.DB.First(&device, "id = ?", "testdevice")
|
||||
|
||||
req := httptest.NewRequest("GET", "/devices/testdevice/firmware", nil)
|
||||
ctx := context.WithValue(req.Context(), userContextKey, &user)
|
||||
ctx = context.WithValue(ctx, deviceContextKey, &device)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.handleFirmwareGenerateGet(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFirmwareGeneratePost(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
var device data.Device
|
||||
s.DB.First(&device, "id = ?", "testdevice")
|
||||
var user data.User
|
||||
s.DB.First(&user, "username = ?", "testuser")
|
||||
|
||||
dummyFirmware := make([]byte, 1024)
|
||||
copy(dummyFirmware, []byte("dummy data"))
|
||||
|
||||
ssidPlaceholder := firmware.PlaceholderSSID + "\x00"
|
||||
copy(dummyFirmware[100:], []byte(ssidPlaceholder))
|
||||
|
||||
passPlaceholder := firmware.PlaceholderPassword + "\x00"
|
||||
copy(dummyFirmware[200:], []byte(passPlaceholder))
|
||||
|
||||
urlPlaceholder := firmware.PlaceholderURL + "\x00"
|
||||
copy(dummyFirmware[300:], []byte(urlPlaceholder))
|
||||
|
||||
firmwareDir := filepath.Join(s.DataDir, "firmware")
|
||||
if err := os.MkdirAll(firmwareDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create firmware directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(firmwareDir, "tidbyt-gen1.bin"), dummyFirmware, 0644); err != nil {
|
||||
t.Fatalf("Failed to write dummy firmware file: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("wifi_ap", "TestSSID")
|
||||
form.Add("wifi_password", "TestPass")
|
||||
form.Add("img_url", "http://example.com/image")
|
||||
|
||||
req := httptest.NewRequest("POST", "/devices/testdevice/firmware", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
ctx := context.WithValue(req.Context(), userContextKey, &user)
|
||||
ctx = context.WithValue(ctx, deviceContextKey, &device)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.handleFirmwareGeneratePost(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
||||
rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if rr.Header().Get("Content-Type") != "application/octet-stream" {
|
||||
t.Errorf("Expected content type application/octet-stream, got %s", rr.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
if rr.Body.Len() == 0 {
|
||||
t.Error("Expected firmware binary in response")
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,6 @@ import (
|
||||
"tronbyt-server/web"
|
||||
)
|
||||
|
||||
func (s *Server) getDeviceWebPDir(deviceID string) string {
|
||||
path := filepath.Join(s.DataDir, "webp", deviceID)
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
slog.Error("Failed to create device webp directory", "path", path, "error", err)
|
||||
// Non-fatal, continue.
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data.Device, user *data.User) bool {
|
||||
if app.Path == nil || *app.Path == "" {
|
||||
return false
|
||||
@@ -58,9 +49,9 @@ func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data
|
||||
}
|
||||
|
||||
// 3. Starlark App - Check Interval
|
||||
now := time.Now().Unix()
|
||||
// uinterval is minutes
|
||||
if now-app.LastRender > int64(app.UInterval*60) {
|
||||
now := time.Now()
|
||||
// uinterval is seconds
|
||||
if time.Since(app.LastRender) > time.Duration(app.UInterval)*time.Second {
|
||||
slog.Info("Rendering app", "app", appBasename)
|
||||
|
||||
// Config
|
||||
@@ -74,7 +65,7 @@ func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data
|
||||
}
|
||||
|
||||
// Add default config
|
||||
deviceTimezone := getDeviceTimezone(device)
|
||||
deviceTimezone := device.GetTimezone()
|
||||
config["$tz"] = deviceTimezone
|
||||
if device.Location.Lat != "" {
|
||||
config["$lat"] = device.Location.Lat
|
||||
@@ -90,8 +81,31 @@ func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data
|
||||
|
||||
// Filters
|
||||
var filters []string
|
||||
if app.ColorFilter != nil && *app.ColorFilter != "" {
|
||||
filters = append(filters, string(*app.ColorFilter))
|
||||
|
||||
// Determine base device filter
|
||||
var deviceFilter data.ColorFilter
|
||||
if GetNightModeIsActive(device) && device.NightColorFilter != nil {
|
||||
deviceFilter = *device.NightColorFilter
|
||||
} else if device.ColorFilter != nil {
|
||||
deviceFilter = *device.ColorFilter
|
||||
} else {
|
||||
deviceFilter = data.ColorFilterNone
|
||||
}
|
||||
|
||||
appFilter := data.ColorFilterInherit
|
||||
if app.ColorFilter != nil {
|
||||
appFilter = *app.ColorFilter
|
||||
}
|
||||
|
||||
if appFilter != data.ColorFilterInherit {
|
||||
if appFilter != data.ColorFilterNone {
|
||||
filters = append(filters, string(appFilter))
|
||||
}
|
||||
} else {
|
||||
// Inherit from device
|
||||
if deviceFilter != data.ColorFilterNone {
|
||||
filters = append(filters, string(deviceFilter))
|
||||
}
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
@@ -103,7 +117,7 @@ func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data
|
||||
time.Duration(appInterval)*time.Second,
|
||||
30*time.Second,
|
||||
true,
|
||||
data.DeviceSupports2x(device.Type),
|
||||
device.Type.Supports2x(),
|
||||
&deviceTimezone,
|
||||
device.Locale,
|
||||
filters,
|
||||
@@ -127,7 +141,7 @@ func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data
|
||||
// Update App State in DB
|
||||
updates := map[string]any{
|
||||
"last_render": now,
|
||||
"last_render_dur": renderDur.Nanoseconds(),
|
||||
"last_render_dur": renderDur,
|
||||
"empty_last_render": !success,
|
||||
"render_messages": data.StringSlice(messages),
|
||||
}
|
||||
@@ -135,7 +149,7 @@ func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data
|
||||
|
||||
// Update in-memory object (passed pointer)
|
||||
app.LastRender = now
|
||||
app.LastRenderDur = renderDur.Nanoseconds()
|
||||
app.LastRenderDur = renderDur
|
||||
app.EmptyLastRender = !success
|
||||
app.RenderMessages = messages
|
||||
|
||||
@@ -152,20 +166,15 @@ func (s *Server) possiblyRender(ctx context.Context, app *data.App, device *data
|
||||
}
|
||||
|
||||
func (s *Server) resolveAppPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(s.DataDir, path)
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
slog.Error("Failed to resolve absolute path", "path", path, "error", err)
|
||||
return path
|
||||
}
|
||||
return filepath.Join(s.DataDir, path)
|
||||
}
|
||||
|
||||
func getDeviceTimezone(device *data.Device) string {
|
||||
if device.Timezone != nil && *device.Timezone != "" {
|
||||
return *device.Timezone
|
||||
}
|
||||
if device.Location.Timezone != "" {
|
||||
return device.Location.Timezone
|
||||
}
|
||||
return "UTC" // Default
|
||||
return abs
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
@@ -214,6 +223,11 @@ func (s *Server) sendDefaultImage(w http.ResponseWriter, r *http.Request, device
|
||||
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
|
||||
brightness := device.Brightness
|
||||
if GetNightModeIsActive(device) {
|
||||
brightness = device.NightBrightness
|
||||
} else if GetDimModeIsActive(device) && device.DimBrightness != nil {
|
||||
brightness = *device.DimBrightness
|
||||
}
|
||||
w.Header().Set("Tronbyt-Brightness", fmt.Sprintf("%d", brightness))
|
||||
|
||||
dwell := device.DefaultInterval
|
||||
|
||||
@@ -3,7 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -13,71 +13,6 @@ import (
|
||||
"tronbyt-server/internal/data"
|
||||
)
|
||||
|
||||
// handleNextApp is the handler for GET /v0/devices/{id}/next
|
||||
func (s *Server) handleNextApp(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
var device *data.Device
|
||||
if d, err := DeviceFromContext(r.Context()); err == nil {
|
||||
device = d
|
||||
} else if u, err := UserFromContext(r.Context()); err == nil {
|
||||
for i := range u.Devices {
|
||||
if u.Devices[i].ID == id {
|
||||
device = &u.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: Fetch from DB directly (No Auth required for device operation)
|
||||
var d data.Device
|
||||
if err := s.DB.Preload("Apps").First(&d, "id = ?", id).Error; err == nil {
|
||||
device = &d
|
||||
}
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if len(device.Apps) == 0 {
|
||||
var reloaded data.Device
|
||||
if err := s.DB.Preload("Apps").First(&reloaded, "id = ?", device.ID).Error; err == nil {
|
||||
device = &reloaded
|
||||
}
|
||||
}
|
||||
|
||||
user, _ := UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
var owner data.User
|
||||
s.DB.First(&owner, "username = ?", device.Username)
|
||||
user = &owner
|
||||
}
|
||||
|
||||
imgData, app, err := s.GetNextAppImage(r.Context(), device, user)
|
||||
if err != nil {
|
||||
// Send default image if error (or not found)
|
||||
s.sendDefaultImage(w, r, device)
|
||||
return
|
||||
}
|
||||
|
||||
// Send Headers
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
w.Header().Set("Tronbyt-Brightness", fmt.Sprintf("%d", device.Brightness))
|
||||
|
||||
dwell := device.DefaultInterval
|
||||
if app != nil && app.DisplayTime > 0 {
|
||||
dwell = app.DisplayTime
|
||||
}
|
||||
w.Header().Set("Tronbyt-Dwell-Secs", fmt.Sprintf("%d", dwell))
|
||||
|
||||
if _, err := w.Write(imgData); err != nil {
|
||||
slog.Error("Failed to write image data to response", "error", err)
|
||||
// Log error, but can't change HTTP status after writing headers.
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetNextAppImage(ctx context.Context, device *data.Device, user *data.User) ([]byte, *data.App, error) {
|
||||
// 1. Check Pushed Ephemeral Images (__*)
|
||||
pushedDir := filepath.Join(s.DataDir, "webp", device.ID, "pushed")
|
||||
@@ -218,6 +153,16 @@ func (s *Server) determineNextApp(device *data.Device, user *data.User) (*data.A
|
||||
shouldDisplay = true
|
||||
} else if isInterstitialPos {
|
||||
shouldDisplay = true
|
||||
// Interstitial Logic: Skip if previous regular app (at index-1) is skipped
|
||||
// Note: expanded list is always [App, Interstitial, App, Interstitial...]
|
||||
// So an interstitial at index i corresponds to App at i-1.
|
||||
if nextIndex > 0 {
|
||||
prevApp := expanded[nextIndex-1]
|
||||
prevActive := prevApp.Enabled && IsAppScheduleActive(&prevApp, device)
|
||||
if !prevActive {
|
||||
shouldDisplay = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
active := candidate.Enabled && IsAppScheduleActive(&candidate, device)
|
||||
if active {
|
||||
|
||||
@@ -43,6 +43,40 @@ func GetNightModeIsActive(device *data.Device) bool {
|
||||
return currentHM >= start && currentHM <= end
|
||||
}
|
||||
|
||||
// GetDimModeIsActive checks if dim mode is active (dimming without full night mode).
|
||||
func GetDimModeIsActive(device *data.Device) bool {
|
||||
dimTime := device.DimTime
|
||||
if dimTime == nil || *dimTime == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get Device Timezone
|
||||
loc := time.Local
|
||||
if device.Timezone != nil {
|
||||
if l, err := time.LoadLocation(*device.Timezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
} else if device.Location.Timezone != "" {
|
||||
if l, err := time.LoadLocation(device.Location.Timezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
|
||||
currentTime := time.Now().In(loc)
|
||||
currentHM := currentTime.Format("15:04")
|
||||
|
||||
start := *dimTime
|
||||
end := "06:00" // Default
|
||||
if device.NightEnd != "" {
|
||||
end = device.NightEnd
|
||||
}
|
||||
|
||||
if start > end {
|
||||
return currentHM >= start || currentHM <= end
|
||||
}
|
||||
return currentHM >= start && currentHM <= end
|
||||
}
|
||||
|
||||
// IsAppScheduleActive checks if an app's schedule is active.
|
||||
func IsAppScheduleActive(app *data.App, device *data.Device) bool {
|
||||
// 1. Get Device Timezone
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -13,18 +14,21 @@ import (
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
dbName := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open DB: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}); err != nil {
|
||||
if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil {
|
||||
t.Fatalf("Failed to migrate DB: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Settings{
|
||||
SecretKey: "testsecret",
|
||||
}
|
||||
// Pre-seed settings to avoid "record not found" logs during NewServer
|
||||
db.Create(&data.Setting{Key: "secret_key", Value: "testsecret"})
|
||||
db.Create(&data.Setting{Key: "system_apps_repo", Value: ""})
|
||||
|
||||
cfg := &config.Settings{}
|
||||
|
||||
s := NewServer(db, cfg)
|
||||
s.DataDir = t.TempDir()
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
session.Values["webauthn_session"] = sessionData
|
||||
if err := session.Save(r, w); err != nil {
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save WebAuthn session data", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -223,7 +223,7 @@ func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
delete(session.Values, "webauthn_session")
|
||||
if err := session.Save(r, w); err != nil {
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session after WebAuthn registration", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -249,7 +249,7 @@ func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request
|
||||
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
session.Values["webauthn_session"] = sessionData
|
||||
if err := session.Save(r, w); err != nil {
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save WebAuthn login session data", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -367,7 +367,7 @@ func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
session.Values["username"] = cred.UserID
|
||||
delete(session.Values, "webauthn_session")
|
||||
if err := session.Save(r, w); err != nil {
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session after WebAuthn login", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -25,80 +25,6 @@ type ClientInfo struct {
|
||||
MACAddress string `json:"macAddress"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.PathValue("id")
|
||||
|
||||
var device data.Device
|
||||
if err := s.DB.Preload("Apps").First(&device, "id = ?", deviceID).Error; err != nil {
|
||||
slog.Warn("WS connection rejected: device not found", "id", deviceID)
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var user data.User
|
||||
s.DB.First(&user, "username = ?", device.Username)
|
||||
|
||||
conn, err := s.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
slog.Error("WS upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
slog.Error("Failed to close WS connection", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("WS Connected", "device", deviceID)
|
||||
|
||||
// Update protocol type
|
||||
s.DB.Model(&device).Update("info", data.JSONMap{"protocol_type": "ws"})
|
||||
|
||||
ch := s.Broadcaster.Subscribe(deviceID)
|
||||
defer s.Broadcaster.Unsubscribe(deviceID, ch)
|
||||
|
||||
ackCh := make(chan WSMessage, 10)
|
||||
stopCh := make(chan struct{})
|
||||
|
||||
// Read loop to handle ping/pong/close and client messages
|
||||
go func() {
|
||||
defer close(stopCh)
|
||||
for {
|
||||
var msg WSMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
slog.Info("WS closed unexpectedly", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Message
|
||||
if msg.ClientInfo != nil {
|
||||
// Update Device Info
|
||||
device.Info.FirmwareVersion = msg.ClientInfo.FirmwareVersion
|
||||
device.Info.FirmwareType = msg.ClientInfo.FirmwareType
|
||||
if msg.ClientInfo.ProtocolVersion != nil {
|
||||
device.Info.ProtocolVersion = msg.ClientInfo.ProtocolVersion
|
||||
}
|
||||
device.Info.MACAddress = msg.ClientInfo.MACAddress
|
||||
|
||||
if err := s.DB.Model(&device).Update("info", device.Info).Error; err != nil {
|
||||
slog.Error("Failed to update device info", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Queued != nil || msg.Displaying != nil {
|
||||
select {
|
||||
case ackCh <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
s.wsWriteLoop(r.Context(), conn, &device, &user, ackCh, ch, stopCh)
|
||||
}
|
||||
|
||||
func (s *Server) wsWriteLoop(ctx context.Context, conn *websocket.Conn, initialDevice *data.Device, user *data.User, ackCh <-chan WSMessage, broadcastCh <-chan struct{}, stopCh <-chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
@@ -109,7 +35,7 @@ func (s *Server) wsWriteLoop(ctx context.Context, conn *websocket.Conn, initialD
|
||||
|
||||
// Reload device to get latest state (protocol version, brightness, etc.)
|
||||
var device data.Device
|
||||
if err := s.DB.First(&device, "id = ?", initialDevice.ID).Error; err != nil {
|
||||
if err := s.DB.Preload("Apps").First(&device, "id = ?", initialDevice.ID).Error; err != nil {
|
||||
slog.Error("Device gone", "id", initialDevice.ID)
|
||||
return
|
||||
}
|
||||
@@ -186,3 +112,74 @@ func (s *Server) wsWriteLoop(ctx context.Context, conn *websocket.Conn, initialD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := s.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
slog.Error("Dashboard WS upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
slog.Error("Failed to close Dashboard WS connection", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Debug("Dashboard WS Connected", "username", username)
|
||||
|
||||
// Subscribe to user-specific updates
|
||||
ch := s.Broadcaster.Subscribe("user:" + username)
|
||||
defer s.Broadcaster.Unsubscribe("user:"+username, ch)
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
// Read loop (handle ping/pong/close)
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
slog.Info("Dashboard WS read error, disconnecting", "username", username, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Write loop
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ch:
|
||||
// A device/app update has occurred for this user
|
||||
// Send a simple message to trigger a full page refresh or AJAX update
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte("refresh")); err != nil {
|
||||
slog.Error("Failed to write refresh message to Dashboard WS", "username", username, "error", err)
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Keep-alive ping
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
slog.Error("Failed to send Dashboard WS ping", "username", username, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) SetupWebsocketRoutes() {
|
||||
s.Router.HandleFunc("GET /{id}/ws", s.handleWS)
|
||||
s.Router.HandleFunc("GET /ws", s.handleDashboardWS)
|
||||
}
|
||||
|
||||
@@ -1345,5 +1345,14 @@
|
||||
},
|
||||
"Invalid old password": {
|
||||
"other": "Ungültiges altes Passwort."
|
||||
},
|
||||
"Drag apps to reorder or copy from/to another device": {
|
||||
"other": "Apps zum Neuanordnen oder Kopieren von/zu einem anderen Gerät ziehen"
|
||||
},
|
||||
"Administrator": {
|
||||
"other": "Administrator"
|
||||
},
|
||||
"Copy to...": {
|
||||
"other": "Kopieren nach..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1343,6 +1343,9 @@
|
||||
"Invalid username or password": {
|
||||
"other": "Invalid username or password"
|
||||
},
|
||||
"Administrator": {
|
||||
"other": "Administrator"
|
||||
},
|
||||
"Invalid old password": {
|
||||
"other": "Invalid old password"
|
||||
}
|
||||
|
||||
@@ -1027,7 +1027,7 @@ button.action.w3-button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.app-preview .app-img img {
|
||||
.app-preview .app-img {
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
}
|
||||
@@ -1133,3 +1133,36 @@ button.action.w3-button {
|
||||
text-align: left;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Custom dropdown styles for "Copy to..." button */
|
||||
.copy-to-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
padding-right: 25px !important;
|
||||
/* Ensure space for arrow */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-select:hover {
|
||||
background-color: #3f51b5 !important; /* Keep w3-indigo color on hover */
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-select-arrow {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.custom-select option {
|
||||
color: black;
|
||||
}
|
||||
@@ -126,18 +126,18 @@ input[type=submit] {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
-webkit-mask-image: url('/v0/dots');
|
||||
-webkit-mask-image: url('/dots');
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-size: cover;
|
||||
mask-image: url('/v0/dots');
|
||||
mask-image: url('/dots');
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.app-img.is-2x img {
|
||||
-webkit-mask-image: url('/v0/dots?w=128&h=64&r=0.4');
|
||||
mask-image: url('/v0/dots?w=128&h=64&r=0.4');
|
||||
-webkit-mask-image: url('/dots?w=128&h=64&r=0.4');
|
||||
mask-image: url('/dots?w=128&h=64&r=0.4');
|
||||
}
|
||||
|
||||
/* Skeleton loader */
|
||||
|
||||
@@ -18,6 +18,10 @@ function debounce(func, wait) {
|
||||
};
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
applyFilters();
|
||||
}, 300);
|
||||
|
||||
|
||||
|
||||
// Function to update the numeric display in real-time (on slider move)
|
||||
@@ -341,6 +345,43 @@ function deleteApp(deviceId, iname, redirectAfterDelete = false) {
|
||||
});
|
||||
}
|
||||
|
||||
function duplicateAppToDevice(sourceDeviceId, iname, targetDeviceId, targetIname, insertAfter) {
|
||||
console.log('duplicateAppToDevice called:', { sourceDeviceId, iname, targetDeviceId, targetIname, insertAfter });
|
||||
|
||||
if (!sourceDeviceId || !iname || !targetDeviceId) {
|
||||
console.error('Missing required parameters for duplicateAppToDevice');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
if (targetIname) {
|
||||
formData.append('target_iname', targetIname);
|
||||
}
|
||||
formData.append('insert_after', insertAfter ? 'true' : 'false');
|
||||
|
||||
fetch(`/devices/${targetDeviceId}/duplicate_from/${sourceDeviceId}/${iname}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString()
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
console.error('Failed to duplicate app to device');
|
||||
alert('Failed to duplicate app to device. Please try again.');
|
||||
} else {
|
||||
console.log('App duplicated successfully to new device');
|
||||
// Refresh the apps list for the TARGET device
|
||||
refreshAppsList(targetDeviceId);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
alert('An error occurred while duplicating the app. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
// AJAX function to preview an app
|
||||
function previewApp(deviceId, iname, config = null, button = null, translations = null) {
|
||||
const url = `/devices/${deviceId}/${iname}/preview`;
|
||||
@@ -914,16 +955,103 @@ function addDropZones() {
|
||||
bottomDropZone.setAttribute('data-position', 'bottom');
|
||||
container.appendChild(bottomDropZone);
|
||||
|
||||
// Add event listeners to drop zones
|
||||
[topDropZone, bottomDropZone].forEach(zone => {
|
||||
zone.addEventListener('dragover', handleDropZoneDragOver);
|
||||
zone.addEventListener('drop', handleDropZoneDrop);
|
||||
zone.addEventListener('dragenter', handleDropZoneDragEnter);
|
||||
zone.addEventListener('dragleave', handleDropZoneDragLeave);
|
||||
});
|
||||
// Add event listeners to container for dropping on empty space
|
||||
container.addEventListener('dragover', handleContainerDragOver);
|
||||
container.addEventListener('drop', handleContainerDrop);
|
||||
container.addEventListener('dragenter', handleContainerDragEnter);
|
||||
container.addEventListener('dragleave', handleContainerDragLeave);
|
||||
});
|
||||
}
|
||||
|
||||
function handleContainerDragOver(e) {
|
||||
// If we are over an app card or drop zone, let their handlers handle it
|
||||
if (e.target.closest('.app-card') || e.target.closest('.drop-zone')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const container = e.currentTarget;
|
||||
const deviceId = container.id.replace('appsList-', '');
|
||||
|
||||
if (!draggedDeviceId) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (deviceId !== draggedDeviceId) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
} else {
|
||||
// If same device, and we are just hovering over container (not specific card),
|
||||
// we technically could "move" to end, but usually reordering is specific.
|
||||
// However, for consistency let's allow "move" (which will act as append)
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
function handleContainerDragEnter(e) {
|
||||
// If we are over an app card or drop zone, let their handlers handle it
|
||||
if (e.target.closest('.app-card') || e.target.closest('.drop-zone')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const container = e.currentTarget;
|
||||
if (draggedDeviceId) {
|
||||
container.classList.add('drag-over-container');
|
||||
}
|
||||
}
|
||||
|
||||
function handleContainerDragLeave(e) {
|
||||
const container = e.currentTarget;
|
||||
// Only remove if we are leaving the container, not entering a child
|
||||
if (!container.contains(e.relatedTarget)) {
|
||||
container.classList.remove('drag-over-container');
|
||||
}
|
||||
}
|
||||
|
||||
function handleContainerDrop(e) {
|
||||
// If we are over an app card or drop zone, let their handlers handle it
|
||||
if (e.target.closest('.app-card') || e.target.closest('.drop-zone')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const container = e.currentTarget;
|
||||
const deviceId = container.id.replace('appsList-', '');
|
||||
|
||||
container.classList.remove('drag-over-container');
|
||||
|
||||
if (!draggedDeviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropping on container means append to list
|
||||
// Target iname is null, insertAfter doesn't matter much but false implies "append if not found" logic in backend
|
||||
// Actually backend logic: if target_iname not found/null -> append.
|
||||
|
||||
const targetIname = null;
|
||||
const insertAfter = false;
|
||||
|
||||
if (deviceId !== draggedDeviceId) {
|
||||
duplicateAppToDevice(draggedDeviceId, draggedIname, deviceId, targetIname, insertAfter);
|
||||
} else {
|
||||
// Same device - "move to end" or do nothing?
|
||||
// If we drop on empty space of same device, maybe move to end?
|
||||
// reorderApps requires target_iname.
|
||||
// So we need to find the last app.
|
||||
const appCards = container.querySelectorAll('.app-card');
|
||||
if (appCards.length > 0) {
|
||||
const lastApp = appCards[appCards.length - 1];
|
||||
const lastIname = lastApp.getAttribute('data-iname');
|
||||
|
||||
// If we are already the last app, do nothing
|
||||
if (lastIname === draggedIname) return;
|
||||
|
||||
reorderApps(deviceId, draggedIname, lastIname, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupDragAndDrop(card) {
|
||||
// Make the card draggable
|
||||
card.draggable = true;
|
||||
@@ -965,7 +1093,7 @@ function handleDragStart(e) {
|
||||
e.dataTransfer.setData('text/plain', `${deviceId}:${iname}`);
|
||||
}
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
@@ -991,13 +1119,17 @@ function handleDragOver(e) {
|
||||
const card = e.currentTarget;
|
||||
const targetDeviceId = extractDeviceIdFromCard(card);
|
||||
|
||||
// Only allow visual feedback for cards from the same device
|
||||
if (!draggedDeviceId || targetDeviceId !== draggedDeviceId) {
|
||||
if (!draggedDeviceId) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
// Set drop effect based on whether it's the same device (move) or different (copy)
|
||||
if (targetDeviceId !== draggedDeviceId) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
const container = card.closest('[id^="appsList-"]');
|
||||
const isGridView = container && container.classList.contains('apps-grid-view');
|
||||
|
||||
@@ -1035,8 +1167,8 @@ function handleDragEnter(e) {
|
||||
const card = e.currentTarget;
|
||||
const targetDeviceId = extractDeviceIdFromCard(card);
|
||||
|
||||
// Only allow dropping on cards from the same device
|
||||
if (draggedDeviceId && targetDeviceId === draggedDeviceId) {
|
||||
// Only allow dropping on cards from capable devices
|
||||
if (draggedDeviceId) {
|
||||
card.classList.add('drag-over');
|
||||
} else {
|
||||
// Remove any existing visual feedback from invalid targets
|
||||
@@ -1061,37 +1193,38 @@ function handleDrop(e) {
|
||||
|
||||
console.log('Drop target:', { targetDeviceId, targetIname, draggedDeviceId, draggedIname });
|
||||
|
||||
// Only allow dropping on cards from the same device
|
||||
if (!draggedDeviceId || targetDeviceId !== draggedDeviceId) {
|
||||
console.log('Drop rejected: different device or no dragged device');
|
||||
// Only allow dropping if valid drag
|
||||
if (!draggedDeviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow dropping on the same card
|
||||
if (targetIname === draggedIname) {
|
||||
console.log('Drop rejected: same card');
|
||||
return;
|
||||
}
|
||||
let insertAfter = false;
|
||||
|
||||
if (isGridView) {
|
||||
// In grid view, determine insert position based on mouse position
|
||||
const rect = targetCard.getBoundingClientRect();
|
||||
const cardCenterX = rect.left + (rect.width / 2);
|
||||
const cardCenterY = rect.top + (rect.height / 2);
|
||||
|
||||
// Determine if we should insert before or after the target
|
||||
// For grid, we'll use a simple approach: if mouse is in the right half, insert after
|
||||
const insertAfter = e.clientX > cardCenterX;
|
||||
|
||||
// Reorder the apps (same as list view)
|
||||
reorderApps(draggedDeviceId, draggedIname, targetIname, insertAfter);
|
||||
insertAfter = e.clientX > cardCenterX;
|
||||
} else {
|
||||
// In list view, use the original insert logic
|
||||
const rect = targetCard.getBoundingClientRect();
|
||||
const midpoint = rect.top + (rect.height / 2);
|
||||
const insertAfter = e.clientY > midpoint;
|
||||
insertAfter = e.clientY > midpoint;
|
||||
}
|
||||
|
||||
// Reorder the apps
|
||||
if (targetDeviceId !== draggedDeviceId) {
|
||||
// Different device - Duplicate!
|
||||
duplicateAppToDevice(draggedDeviceId, draggedIname, targetDeviceId, targetIname, insertAfter);
|
||||
} else {
|
||||
// Same device - Reorder
|
||||
// Don't allow dropping on the same card
|
||||
if (targetIname === draggedIname) {
|
||||
console.log('Drop rejected: same card');
|
||||
return;
|
||||
}
|
||||
reorderApps(draggedDeviceId, draggedIname, targetIname, insertAfter);
|
||||
}
|
||||
|
||||
@@ -1157,13 +1290,17 @@ function handleDropZoneDragOver(e) {
|
||||
const zone = e.currentTarget;
|
||||
const deviceId = zone.getAttribute('data-device-id');
|
||||
|
||||
// Only allow visual feedback for zones from the same device
|
||||
if (!draggedDeviceId || deviceId !== draggedDeviceId) {
|
||||
// Only allow visual feedback for zones if valid drag
|
||||
if (!draggedDeviceId) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (deviceId !== draggedDeviceId) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
function handleDropZoneDragEnter(e) {
|
||||
@@ -1171,8 +1308,8 @@ function handleDropZoneDragEnter(e) {
|
||||
const zone = e.currentTarget;
|
||||
const deviceId = zone.getAttribute('data-device-id');
|
||||
|
||||
// Only allow dropping on zones from the same device
|
||||
if (draggedDeviceId && deviceId === draggedDeviceId) {
|
||||
// Only allow dropping on zones if valid drag
|
||||
if (draggedDeviceId) {
|
||||
zone.classList.add('active');
|
||||
} else {
|
||||
// Remove any existing visual feedback from invalid targets
|
||||
@@ -1192,8 +1329,8 @@ function handleDropZoneDrop(e) {
|
||||
const deviceId = zone.getAttribute('data-device-id');
|
||||
const position = zone.getAttribute('data-position');
|
||||
|
||||
// Only allow dropping on zones from the same device
|
||||
if (!draggedDeviceId || deviceId !== draggedDeviceId) {
|
||||
// Only allow dropping on zones if valid drag
|
||||
if (!draggedDeviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1201,23 +1338,35 @@ function handleDropZoneDrop(e) {
|
||||
const container = zone.parentElement;
|
||||
const appCards = container.querySelectorAll('.app-card');
|
||||
|
||||
if (appCards.length === 0) {
|
||||
return;
|
||||
}
|
||||
let targetIname = null;
|
||||
let insertAfter = false;
|
||||
|
||||
let targetIname;
|
||||
let insertAfter;
|
||||
|
||||
if (position === 'top') {
|
||||
targetIname = appCards[0].getAttribute('data-iname');
|
||||
if (appCards.length > 0) {
|
||||
if (position === 'top') {
|
||||
targetIname = appCards[0].getAttribute('data-iname');
|
||||
insertAfter = false;
|
||||
} else { // position === 'bottom'
|
||||
targetIname = appCards[appCards.length - 1].getAttribute('data-iname');
|
||||
insertAfter = true;
|
||||
}
|
||||
} else {
|
||||
// Empty list - we are appending to empty device
|
||||
targetIname = null;
|
||||
insertAfter = false;
|
||||
} else { // position === 'bottom'
|
||||
targetIname = appCards[appCards.length - 1].getAttribute('data-iname');
|
||||
insertAfter = true;
|
||||
}
|
||||
|
||||
// Reorder the apps
|
||||
reorderApps(deviceId, draggedIname, targetIname, insertAfter);
|
||||
// Check if cross-device or same device
|
||||
if (deviceId !== draggedDeviceId) {
|
||||
duplicateAppToDevice(draggedDeviceId, draggedIname, deviceId, targetIname, insertAfter);
|
||||
} else {
|
||||
if (appCards.length > 0) {
|
||||
reorderApps(deviceId, draggedIname, targetIname, insertAfter);
|
||||
} else {
|
||||
// Should not happen for same device reorder (cannot be empty if we are dragging an app from it)
|
||||
console.warn("Attempted to reorder in empty device list");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
zone.classList.remove('active');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ define "title" }}{{ t .Localizer "Edit User" }}{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{ if eq .User.Username "admin" }}
|
||||
{{ if .User.IsAdmin }}
|
||||
<h1>{{ t .Localizer "System Administration" }}</h1>
|
||||
|
||||
<h2>{{ t .Localizer "System Apps Repository" }}</h2>
|
||||
@@ -142,7 +142,7 @@
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #333; padding-top: 15px;">
|
||||
<form method="post" action="/set_api_key" style="display: flex; gap: 10px; align-items: flex-end;">
|
||||
<form method="post" action="/auth/set_api_key" style="display: flex; gap: 10px; align-items: flex-end;">
|
||||
<div style="flex: 1;">
|
||||
<label for="api_key" style="display: block; margin-bottom: 5px; font-size: 0.9em;">{{ t .Localizer "User API Key" }}</label>
|
||||
<input type="text" name="api_key" id="api_key" required value="{{ .User.APIKey }}" style="width: 100%; padding: 8px;">
|
||||
|
||||
@@ -32,11 +32,7 @@
|
||||
|
||||
<form method="POST">
|
||||
<label for="username">{{ t .Localizer "Username (alpha-numeric only, no symbols please.)" }}</label>
|
||||
{{ if eq .UserCount 0 }}
|
||||
<input class="w3-input w3-border" name="username" id="username" value="admin" readonly required>
|
||||
{{ else }}
|
||||
<input class="w3-input w3-border" name="username" id="username" required>
|
||||
{{ end }}
|
||||
<br>
|
||||
<label for="password">{{ t .Localizer "Password" }}</label>
|
||||
<input class="w3-input w3-border" type="password" name="password" id="password" required>
|
||||
@@ -44,6 +40,11 @@
|
||||
<label for="email">{{ t .Localizer "Email Address (optional)" }}</label>
|
||||
<input class="w3-input w3-border" type="text" name="email" id="email">
|
||||
<br>
|
||||
{{ if and .User .User.IsAdmin }}
|
||||
<label for="is_admin">{{ t .Localizer "Administrator" }}</label>
|
||||
<input class="w3-check" type="checkbox" name="is_admin" id="is_admin">
|
||||
<br>
|
||||
{{ end }}
|
||||
{{ if eq .UserCount 0 }}
|
||||
<button type="submit" class="w3-button w3-green"><i class="fa-solid fa-user-plus" aria-hidden="true"></i> {{ t .Localizer "Create Admin User" }}</button>
|
||||
{{ else }}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<ul>
|
||||
{{ if .User }}
|
||||
<li><a href="/">{{ t .Localizer "Home" }}</a></li>
|
||||
{{ if eq .User.Username "admin" }}
|
||||
{{ if .User.IsAdmin }}
|
||||
<li>
|
||||
<a href="/auth/register">{{ t .Localizer "Create User" }}</a>
|
||||
</li>
|
||||
@@ -85,7 +85,7 @@
|
||||
<ul>
|
||||
{{ if .User }}
|
||||
<li><a href="/" onclick="closeMobileMenu()">{{ t .Localizer "Home" }}</a></li>
|
||||
{{ if eq .User.Username "admin" }}
|
||||
{{ if .User.IsAdmin }}
|
||||
<li>
|
||||
<a href="/auth/register" onclick="closeMobileMenu()">
|
||||
{{ t .Localizer "Create User" }}
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
<div class="w3-card-4 w3-padding">
|
||||
<header>
|
||||
<h1>{{ .Username }}</h1>
|
||||
{{ if ne .Username "admin" }}
|
||||
<form method="POST" action="/admin/{{ .Username }}/deleteuser" onsubmit="return confirm('{{ t $.Localizer "Delete User?" }}');">
|
||||
<input class="danger" type="submit" value="{{ t $.Localizer "Delete" }}">
|
||||
</form>
|
||||
{{ if ne .Username $.User.Username }}
|
||||
<button class="w3-button w3-red w3-round" onclick="deleteUser('{{ .Username }}')">
|
||||
<i class="fa-solid fa-trash"></i> {{ t $.Localizer "Delete" }}
|
||||
</button>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Devices }}
|
||||
@@ -90,4 +90,22 @@
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<script>
|
||||
function deleteUser(username) {
|
||||
if (!confirm('{{ t .Localizer "Delete User?" }}')) return;
|
||||
|
||||
fetch('/admin/users/' + username, {
|
||||
method: 'DELETE'
|
||||
}).then(response => {
|
||||
if (response.ok || response.redirected) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete user');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
alert('Error deleting user');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
@@ -119,8 +119,8 @@
|
||||
</td>
|
||||
<td>
|
||||
<select name="color_filter" id="color_filter" class="form-control">
|
||||
{{ range $key, $value := .ColorFilterChoices }}
|
||||
<option value="{{ $key }}" {{ if eq (deref $.App.ColorFilter) $key }}selected{{ end }}>{{ t $.Localizer $value }}</option>
|
||||
{{ range .ColorFilterOptions }}
|
||||
<option value="{{ .Value }}" {{ if eq (derefOr $.App.ColorFilter "inherit") .Value }}selected{{ end }}>{{ t $.Localizer .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
<div class="w3-panel w3-yellow w3-border w3-border-amber w3-round-large">
|
||||
<h3><i class="fa-solid fa-triangle-exclamation"></i> {{ t .Localizer "Firmware Files Not Available" }}</h3>
|
||||
<p>{{ t .Localizer "Firmware binary files have not been downloaded yet. Firmware generation will not work until the files are downloaded." }}</p>
|
||||
{{ if eq .User.Username "admin" }}
|
||||
<p>
|
||||
<strong>{{ t .Localizer "Action Required:" }}</strong> {{ t .Localizer "Please go to the" }}
|
||||
{{ if .User.IsAdmin }} <strong>{{ t .Localizer "Action Required:" }}</strong> {{ t .Localizer "Please go to the" }}
|
||||
<a href="/auth/edit#firmware-management" class="w3-text-blue" style="text-decoration: underline;">{{ t .Localizer "Admin page" }}</a>
|
||||
{{ t .Localizer "to download the latest firmware files." }}
|
||||
</p>
|
||||
@@ -29,7 +27,7 @@
|
||||
<div>
|
||||
<strong>{{ t .Localizer "Firmware Image Version" }}:</strong> {{ .FirmwareVersion }}
|
||||
</div>
|
||||
{{ if eq .User.Username "admin" }}
|
||||
{{ if .User.IsAdmin }}
|
||||
<div>
|
||||
<a href="/auth/edit#firmware-management" class="w3-button w3-small w3-gray firmware-management-btn">
|
||||
<i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> {{ t .Localizer "Manage Firmware" }}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
{{ end }}
|
||||
|
||||
{{ range .DevicesWithUIScales }}
|
||||
{{ template "device_card" (dict "Item" . "Localizer" $.Localizer) }}
|
||||
{{ template "device_card" (dict "Item" . "Localizer" $.Localizer "DevicesWithUIScales" $.DevicesWithUIScales) }}
|
||||
<hr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -169,8 +169,8 @@
|
||||
</td>
|
||||
<td>
|
||||
<select name="color_filter" id="color_filter" class="device-settings-input">
|
||||
{{ range $key, $value := .ColorFilterChoices }}
|
||||
<option value="{{ $key }}" {{ if eq (deref $.Device.ColorFilter) $key }}selected{{ end }}>{{ t $.Localizer $value }}</option>
|
||||
{{ range .ColorFilterOptions }}
|
||||
<option value="{{ .Value }}" {{ if eq (derefOr $.Device.ColorFilter "none") .Value }}selected{{ end }}>{{ t $.Localizer .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</td>
|
||||
@@ -368,8 +368,8 @@
|
||||
</td>
|
||||
<td>
|
||||
<select name="night_color_filter" id="night_color_filter" class="device-settings-input">
|
||||
{{ range $key, $value := .ColorFilterChoices }}
|
||||
<option value="{{ $key }}" {{ if eq (deref $.Device.NightColorFilter) $key }}selected{{ end }}>{{ t $.Localizer $value }}</option>
|
||||
{{ range .ColorFilterOptions }}
|
||||
<option value="{{ .Value }}" {{ if eq (derefOr $.Device.NightColorFilter "none") .Value }}selected{{ end }}>{{ t $.Localizer .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
@@ -55,10 +55,29 @@
|
||||
<div class="app-actions-primary">
|
||||
<a class="action w3-button w3-blue w3-round"
|
||||
href="/devices/{{ .Device.ID }}/{{ .App.Iname }}/config"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ t .Localizer "Edit" }}</a>
|
||||
{{ if eq .Device.Info.ProtocolType "WS" }}
|
||||
<button class="action w3-button w3-blue w3-round"
|
||||
onclick="previewApp('{{ .Device.ID }}', '{{ .App.Iname }}', null, this)"><i class="fa-solid fa-play" aria-hidden="true"></i> {{ t .Localizer "Preview" }}</button>
|
||||
{{ end }}
|
||||
<button class="action w3-button w3-green w3-round"
|
||||
onclick="duplicateApp('{{ .Device.ID }}', '{{ .App.Iname }}')"><i class="fa-solid fa-copy" aria-hidden="true"></i> {{ t .Localizer "Duplicate" }}</button>
|
||||
|
||||
{{ if gt (len .DevicesWithUIScales) 1 }}
|
||||
<div class="copy-to-dropdown">
|
||||
<select class="action w3-button w3-indigo w3-round custom-select"
|
||||
onchange="if(this.value) { duplicateAppToDevice('{{ .Device.ID }}', '{{ .App.Iname }}', this.value, null, false); this.selectedIndex=0; }">
|
||||
<option value="" disabled selected>{{ t .Localizer "Copy to..." }}</option>
|
||||
{{ range $item := .DevicesWithUIScales }}
|
||||
{{ $target_device := $item.Device }}
|
||||
{{ if ne $target_device.ID .Device.ID }}
|
||||
<option value="{{ $target_device.ID }}">{{ $target_device.Name }}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</select>
|
||||
<i class="fa fa-caret-down custom-select-arrow"></i>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<button class="action w3-button w3-red w3-round"
|
||||
onclick="deleteApp('{{ .Device.ID }}', '{{ .App.Iname }}')"><i class="fa-solid fa-trash" aria-hidden="true"></i> {{ t .Localizer "Delete" }}</button>
|
||||
</div>
|
||||
|
||||
@@ -92,11 +92,14 @@
|
||||
<i class="fas fa-chevron-up"></i> {{ t .Localizer "Collapsed" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="instruction-text" style="color: #666; font-style: italic;">
|
||||
{{ t .Localizer "Drag apps to reorder or copy from/to another device" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="appsList-{{ .Item.Device.ID }}" class="visible apps-list-view">
|
||||
{{ range .Item.Device.Apps }}
|
||||
{{ template "app_card" (dict "App" . "Device" $.Item.Device "Localizer" $.Localizer) }}
|
||||
{{ template "app_card" (dict "App" . "Device" $.Item.Device "Localizer" $.Localizer "DevicesWithUIScales" $.DevicesWithUIScales) }}
|
||||
<hr class="list-view-separator">
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user