feat: add automatic update checker with version injection
- Add internal/updater package (queries Gitea releases API every 6h) - Inject version at build time via -ldflags (-X main.Version) - Show update badge in header for admin/owner users - Show version on system info page - Add VERSION build arg to Dockerfile - Update docs (deployment, architecture, admin-guide, contributing, README)
This commit is contained in:
@@ -11,7 +11,9 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywarden/
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w -X main.Version=${VERSION}" ./cmd/keywarden/
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
- **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users
|
- **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users
|
||||||
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
|
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
|
||||||
- **Audit Log** — Every action tracked with user, IP, timestamp, and details
|
- **Audit Log** — Every action tracked with user, IP, timestamp, and details
|
||||||
|
- **Update Notifications** — Automatic update check with version badge in the header for admins
|
||||||
- **Encrypted Backup/Restore** — Full database export with password-based encryption
|
- **Encrypted Backup/Restore** — Full database export with password-based encryption
|
||||||
- **Docker-Native** — Single container with embedded SQLite, no external database required
|
- **Docker-Native** — Single container with embedded SQLite, no external database required
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,18 @@ import (
|
|||||||
"git.techniverse.net/scriptos/keywarden/internal/mail"
|
"git.techniverse.net/scriptos/keywarden/internal/mail"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||||
|
"git.techniverse.net/scriptos/keywarden/internal/updater"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
||||||
"git.techniverse.net/scriptos/keywarden/web"
|
"git.techniverse.net/scriptos/keywarden/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Version is set at build time via -ldflags:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X main.Version=v1.0.0" ./cmd/keywarden/
|
||||||
|
//
|
||||||
|
// When building with Docker, pass --build-arg VERSION=v1.0.0
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Handle CLI subcommands before starting the server
|
// Handle CLI subcommands before starting the server
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
@@ -47,7 +55,7 @@ func main() {
|
|||||||
// Initialize structured logging
|
// Initialize structured logging
|
||||||
logging.Init(cfg.LogLevel)
|
logging.Init(cfg.LogLevel)
|
||||||
|
|
||||||
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment")
|
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", Version)
|
||||||
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
||||||
|
|
||||||
// Validate data paths – relative paths inside a container bypass the
|
// Validate data paths – relative paths inside a container bypass the
|
||||||
@@ -117,8 +125,11 @@ func main() {
|
|||||||
logging.Info("Base URL: %s", cfg.BaseURL)
|
logging.Info("Base URL: %s", cfg.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize update checker
|
||||||
|
updaterSvc := updater.NewService(Version)
|
||||||
|
|
||||||
// Setup HTTP handlers
|
// Setup HTTP handlers
|
||||||
handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL)
|
handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL, updaterSvc)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
handler.RegisterRoutes(mux)
|
handler.RegisterRoutes(mux)
|
||||||
@@ -148,6 +159,10 @@ func main() {
|
|||||||
workerSvc.Start()
|
workerSvc.Start()
|
||||||
defer workerSvc.Stop()
|
defer workerSvc.Stop()
|
||||||
|
|
||||||
|
// Start update checker
|
||||||
|
updaterSvc.Start()
|
||||||
|
defer updaterSvc.Stop()
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
logging.Info("Server starting on http://0.0.0.0%s", addr)
|
logging.Info("Server starting on http://0.0.0.0%s", addr)
|
||||||
@@ -278,7 +293,7 @@ func handleResetPassword(args []string) {
|
|||||||
|
|
||||||
// printUsage displays available CLI subcommands
|
// printUsage displays available CLI subcommands
|
||||||
func printUsage() {
|
func printUsage() {
|
||||||
fmt.Println("Keywarden - Centralized SSH Key Management and Deployment")
|
fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", Version)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Usage:")
|
fmt.Println("Usage:")
|
||||||
fmt.Println(" keywarden Start the server")
|
fmt.Println(" keywarden Start the server")
|
||||||
|
|||||||
@@ -209,12 +209,19 @@ Deleting a user removes their SSH keys, server records, and all related data (CA
|
|||||||
|
|
||||||
Navigate to **System** to view runtime information:
|
Navigate to **System** to view runtime information:
|
||||||
|
|
||||||
|
- Application version (with update badge if a newer release is available)
|
||||||
- Go version, OS, architecture
|
- Go version, OS, architecture
|
||||||
- CPU count, goroutine count
|
- CPU count, goroutine count
|
||||||
- Memory allocation
|
- Memory allocation
|
||||||
- Runtime environment (Docker or native)
|
- Runtime environment (Docker or native)
|
||||||
- Hostname and uptime
|
- Hostname and uptime
|
||||||
|
|
||||||
|
## Update Notifications
|
||||||
|
|
||||||
|
Keywarden automatically checks for new releases in the background by querying the Gitea releases API. If a newer version is available, a yellow update badge is displayed in the top header for **Admin** and **Owner** users. The badge links directly to the release page on Gitea.
|
||||||
|
|
||||||
|
The update checker is only active when the application was built with a version tag (via `--build-arg VERSION=...`). Development builds (`dev`) skip the check entirely.
|
||||||
|
|
||||||
## Admin Settings (Owner Only)
|
## Admin Settings (Owner Only)
|
||||||
|
|
||||||
See [Roles & Permissions](roles.md) for details on which settings are owner-only.
|
See [Roles & Permissions](roles.md) for details on which settings are owner-only.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ internal/
|
|||||||
security/ ← CSRF, security headers, rate limiting, proxy detection
|
security/ ← CSRF, security headers, rate limiting, proxy detection
|
||||||
servers/ ← Server and server group management, access assignments
|
servers/ ← Server and server group management, access assignments
|
||||||
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
|
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
|
||||||
|
updater/ ← Background update checker (Gitea releases API)
|
||||||
worker/ ← Background key enforcement worker (Bastillion-style)
|
worker/ ← Background key enforcement worker (Bastillion-style)
|
||||||
web/
|
web/
|
||||||
embed.go ← Go embed directives for templates and static files
|
embed.go ← Go embed directives for templates and static files
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ go mod download
|
|||||||
# Build
|
# Build
|
||||||
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
|
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
|
||||||
|
|
||||||
|
# Build with version (optional, enables update checker)
|
||||||
|
CGO_ENABLED=1 go build -ldflags="-X 'main.Version=v1.0.0'" -o keywarden ./cmd/keywarden/
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
./keywarden
|
./keywarden
|
||||||
```
|
```
|
||||||
@@ -82,7 +85,8 @@ keywarden/
|
|||||||
│ │ ├── ratelimit.go # IP-based rate limiting middleware
|
│ │ ├── ratelimit.go # IP-based rate limiting middleware
|
||||||
│ │ └── sizelimit.go # Request body size limit middleware
|
│ │ └── sizelimit.go # Request body size limit middleware
|
||||||
│ ├── servers/servers.go # Server and group management, access assignments
|
│ ├── servers/servers.go # Server and group management, access assignments
|
||||||
│ └── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
|
│ ├── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
|
||||||
|
│ └── updater/updater.go # Background update checker (Gitea releases API)
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── embed.go # Go embed directives
|
│ ├── embed.go # Go embed directives
|
||||||
│ ├── static/ # CSS, JS, fonts (Tabler UI)
|
│ ├── static/ # CSS, JS, fonts (Tabler UI)
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ docker compose build
|
|||||||
|
|
||||||
# Or build manually
|
# Or build manually
|
||||||
docker build -t keywarden .
|
docker build -t keywarden .
|
||||||
|
|
||||||
|
# Build with a specific version tag (recommended for releases)
|
||||||
|
docker build --build-arg VERSION=v1.0.0 -t keywarden:v1.0.0 .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-Stage Build
|
### Multi-Stage Build
|
||||||
@@ -43,6 +46,8 @@ The Dockerfile uses a two-stage build:
|
|||||||
|
|
||||||
The runtime container runs as a non-root user (`keywarden`).
|
The runtime container runs as a non-root user (`keywarden`).
|
||||||
|
|
||||||
|
The build accepts an optional `VERSION` build arg (e.g. `--build-arg VERSION=v1.0.0`) which is injected into the binary via `-ldflags`. This enables the built-in update checker to compare the running version against the latest Gitea release. If omitted, the version defaults to `dev` and the update checker is disabled.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
A complete `docker-compose.yml`:
|
A complete `docker-compose.yml`:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import (
|
|||||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||||
|
"git.techniverse.net/scriptos/keywarden/internal/updater"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ type Handler struct {
|
|||||||
cron *cron.Service
|
cron *cron.Service
|
||||||
worker *worker.Service
|
worker *worker.Service
|
||||||
mail *mail.Service
|
mail *mail.Service
|
||||||
|
updater *updater.Service
|
||||||
db *database.DB // direct database access for backup/restore
|
db *database.DB // direct database access for backup/restore
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
sessions map[string]*sessionData // cookie -> session data with timeout tracking
|
sessions map[string]*sessionData // cookie -> session data with timeout tracking
|
||||||
@@ -251,7 +253,7 @@ func formatUptime(start time.Time) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handler
|
// New creates a new Handler
|
||||||
func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string) *Handler {
|
func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string, updaterSvc *updater.Service) *Handler {
|
||||||
// Create sub-FS so /static/css/... maps to static/css/... in embed
|
// Create sub-FS so /static/css/... maps to static/css/... in embed
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -273,6 +275,7 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
|
|||||||
cron: cronSvc,
|
cron: cronSvc,
|
||||||
worker: workerSvc,
|
worker: workerSvc,
|
||||||
mail: mailSvc,
|
mail: mailSvc,
|
||||||
|
updater: updaterSvc,
|
||||||
db: db,
|
db: db,
|
||||||
sessions: make(map[string]*sessionData),
|
sessions: make(map[string]*sessionData),
|
||||||
pending: make(map[string]int64),
|
pending: make(map[string]int64),
|
||||||
@@ -302,6 +305,18 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
|||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
},
|
},
|
||||||
|
"appVersion": func() string {
|
||||||
|
return h.updater.CurrentVersion()
|
||||||
|
},
|
||||||
|
"updateAvailable": func() bool {
|
||||||
|
return h.updater.HasUpdate()
|
||||||
|
},
|
||||||
|
"latestVersion": func() string {
|
||||||
|
return h.updater.LatestVersion()
|
||||||
|
},
|
||||||
|
"releaseURL": func() string {
|
||||||
|
return h.updater.ReleaseURL()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
||||||
|
|||||||
193
internal/updater/updater.go
Normal file
193
internal/updater/updater.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// Keywarden - Centralized SSH Key Management and Deployment
|
||||||
|
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Gitea API endpoint for releases
|
||||||
|
releasesAPI = "https://git.techniverse.net/api/v1/repos/scriptos/keywarden/releases?limit=5"
|
||||||
|
// How often to check for updates
|
||||||
|
checkInterval = 6 * time.Hour
|
||||||
|
// HTTP timeout for API requests
|
||||||
|
httpTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// giteaRelease represents the relevant fields from the Gitea releases API
|
||||||
|
type giteaRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service checks for new releases in the background
|
||||||
|
type Service struct {
|
||||||
|
currentVersion string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
latestVersion string
|
||||||
|
releaseURL string
|
||||||
|
hasUpdate bool
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates an update checker. Pass the current application version
|
||||||
|
// (e.g. "v1.0.0" or "dev"). The checker runs in the background and queries
|
||||||
|
// the Gitea releases API periodically.
|
||||||
|
func NewService(currentVersion string) *Service {
|
||||||
|
return &Service{
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins periodic update checks in the background.
|
||||||
|
func (s *Service) Start() {
|
||||||
|
// Don't check if running a dev build
|
||||||
|
if s.currentVersion == "" || s.currentVersion == "dev" {
|
||||||
|
logging.Info("Update checker disabled (development build)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.Info("Update checker started (current version: %s, checking every %s)", s.currentVersion, checkInterval)
|
||||||
|
|
||||||
|
go s.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop signals the background goroutine to exit.
|
||||||
|
func (s *Service) Stop() {
|
||||||
|
close(s.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUpdate returns true if a newer version is available.
|
||||||
|
func (s *Service) HasUpdate() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.hasUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestVersion returns the tag name of the latest release (e.g. "v1.2.0").
|
||||||
|
func (s *Service) LatestVersion() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.latestVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseURL returns the HTML link to the latest release page on Gitea.
|
||||||
|
func (s *Service) ReleaseURL() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.releaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentVersion returns the running application version.
|
||||||
|
func (s *Service) CurrentVersion() string {
|
||||||
|
return s.currentVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) run() {
|
||||||
|
// Initial check shortly after startup
|
||||||
|
timer := time.NewTimer(30 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
s.check()
|
||||||
|
timer.Reset(checkInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) check() {
|
||||||
|
client := &http.Client{Timeout: httpTimeout}
|
||||||
|
|
||||||
|
resp, err := client.Get(releasesAPI)
|
||||||
|
if err != nil {
|
||||||
|
logging.Warn("Update check failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logging.Warn("Update check: Gitea API returned status %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var releases []giteaRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||||
|
logging.Warn("Update check: failed to parse response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the latest stable release (not draft, not prerelease)
|
||||||
|
for _, rel := range releases {
|
||||||
|
if rel.Draft || rel.Prerelease || rel.TagName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.latestVersion = rel.TagName
|
||||||
|
s.releaseURL = rel.HTMLURL
|
||||||
|
s.hasUpdate = isNewer(rel.TagName, s.currentVersion)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.HasUpdate() {
|
||||||
|
logging.Info("New version available: %s (current: %s)", rel.TagName, s.currentVersion)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewer returns true if latest is a higher version than current.
|
||||||
|
// Both may optionally have a "v" prefix (e.g. "v1.2.3").
|
||||||
|
func isNewer(latest, current string) bool {
|
||||||
|
latestParts := parseVersion(latest)
|
||||||
|
currentParts := parseVersion(current)
|
||||||
|
|
||||||
|
for i := 0; i < len(latestParts) || i < len(currentParts); i++ {
|
||||||
|
l, c := 0, 0
|
||||||
|
if i < len(latestParts) {
|
||||||
|
l = latestParts[i]
|
||||||
|
}
|
||||||
|
if i < len(currentParts) {
|
||||||
|
c = currentParts[i]
|
||||||
|
}
|
||||||
|
if l > c {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if l < c {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVersion strips the "v" prefix and splits "1.2.3" into [1, 2, 3].
|
||||||
|
func parseVersion(v string) []int {
|
||||||
|
v = strings.TrimPrefix(v, "v")
|
||||||
|
parts := strings.Split(v, ".")
|
||||||
|
nums := make([]int, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
return nums
|
||||||
|
}
|
||||||
@@ -582,6 +582,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Spacer -->
|
<!-- Spacer -->
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
|
<!-- Update Available Badge (Admin/Owner only) -->
|
||||||
|
{{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}}
|
||||||
|
<div class="nav-item d-none d-md-flex me-2">
|
||||||
|
<a href="{{releaseURL}}" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Update verfügbar: {{latestVersion}} (aktuell: {{appVersion}})">
|
||||||
|
<i class="ti ti-download" style="color: #fbbf24;"></i>
|
||||||
|
<span style="color: #fbbf24;">Update {{latestVersion}}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}{{end}}
|
||||||
<!-- Repository Link -->
|
<!-- Repository Link -->
|
||||||
<div class="nav-item d-none d-md-flex me-2">
|
<div class="nav-item d-none d-md-flex me-2">
|
||||||
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
|
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{with .SystemInfo}}
|
{{with .SystemInfo}}
|
||||||
<div class="datagrid">
|
<div class="datagrid">
|
||||||
|
<div class="datagrid-item">
|
||||||
|
<div class="datagrid-title">Keywarden Version</div>
|
||||||
|
<div class="datagrid-content">
|
||||||
|
<span class="badge bg-blue-lt">{{appVersion}}</span>
|
||||||
|
{{if updateAvailable}}
|
||||||
|
<a href="{{releaseURL}}" target="_blank" rel="noopener noreferrer" class="badge bg-yellow-lt ms-1" title="Update verfügbar">
|
||||||
|
<i class="ti ti-download"></i> {{latestVersion}} verfügbar
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="datagrid-item">
|
<div class="datagrid-item">
|
||||||
<div class="datagrid-title">Runtime Environment</div>
|
<div class="datagrid-title">Runtime Environment</div>
|
||||||
<div class="datagrid-content">
|
<div class="datagrid-content">
|
||||||
|
|||||||
Reference in New Issue
Block a user