mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-06-03 23:12:56 +00:00
🚀 Dash - Homelab Dashboard
A clean, customizable homelab dashboard inspired by CasaOS. Features: - Empty-first dashboard (no demo data) - 3 themes: Light, Dark, CasaOS glassmorphism - Widgets: Clock (multi-timezone), Pi-hole, Memos, Immich, Image - Drag & drop app organization - Grid + list view for apps - Groups with collapse/expand - Proper widget refresh handling - Visual timezone picker - Square app cards with hover effects Stack: Go + Gin + PostgreSQL + Next.js 15 + React 19 + Tailwind CSS + shadcn/ui
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"dash/backend/internal/store"
|
||||
"dash/backend/internal/validation"
|
||||
"dash/backend/internal/widgets"
|
||||
)
|
||||
|
||||
func NormalizeService(input store.ServiceInput) (store.ServiceInput, error) {
|
||||
name, err := validation.Name(input.Name)
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
input.Name = name
|
||||
if input.GroupID != nil {
|
||||
if _, err := uuid.Parse(*input.GroupID); err != nil {
|
||||
return store.ServiceInput{}, errors.New("groupId must be a UUID")
|
||||
}
|
||||
}
|
||||
if input.IconAssetID != nil {
|
||||
if _, err := uuid.Parse(*input.IconAssetID); err != nil {
|
||||
return store.ServiceInput{}, errors.New("iconAssetId must be a UUID")
|
||||
}
|
||||
}
|
||||
if input.IconURL != nil && input.IconAssetID != nil {
|
||||
return store.ServiceInput{}, errors.New("iconUrl and iconAssetId are mutually exclusive")
|
||||
}
|
||||
|
||||
if input.IconURL != nil {
|
||||
iconURL, err := validation.OptionalAbsoluteHTTP(*input.IconURL, "iconUrl")
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
input.IconURL = iconURL
|
||||
}
|
||||
if len(input.URLs) == 0 {
|
||||
return store.ServiceInput{}, errors.New("service requires at least one URL")
|
||||
}
|
||||
|
||||
primaryCount := 0
|
||||
seenURLIDs := map[string]struct{}{}
|
||||
for i := range input.URLs {
|
||||
label, err := validation.Label(input.URLs[i].Label)
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
if err := validation.URLKind(input.URLs[i].Kind); err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
serviceURL, err := validation.AbsoluteHTTP(input.URLs[i].URL, "url")
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
if input.URLs[i].ID != nil {
|
||||
if _, err := uuid.Parse(*input.URLs[i].ID); err != nil {
|
||||
return store.ServiceInput{}, errors.New("service URL id must be a UUID")
|
||||
}
|
||||
if _, ok := seenURLIDs[*input.URLs[i].ID]; ok {
|
||||
return store.ServiceInput{}, errors.New("duplicate service URL id")
|
||||
}
|
||||
seenURLIDs[*input.URLs[i].ID] = struct{}{}
|
||||
}
|
||||
input.URLs[i].Label = label
|
||||
input.URLs[i].URL = serviceURL
|
||||
if input.URLs[i].IsPrimary {
|
||||
primaryCount++
|
||||
}
|
||||
}
|
||||
if primaryCount > 1 {
|
||||
return store.ServiceInput{}, errors.New("only one primary URL allowed")
|
||||
}
|
||||
if primaryCount == 0 {
|
||||
input.URLs[0].IsPrimary = true
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func NormalizeGroup(input store.GroupInput, requireName bool) (store.GroupInput, error) {
|
||||
if input.Name == nil {
|
||||
if requireName {
|
||||
return store.GroupInput{}, errors.New("name is required")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
name, err := validation.Name(*input.Name)
|
||||
if err != nil {
|
||||
return store.GroupInput{}, err
|
||||
}
|
||||
input.Name = &name
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func NormalizeWidget(input store.WidgetInput, requireType bool) (store.WidgetInput, error) {
|
||||
if requireType || input.Type != "" {
|
||||
if err := validation.WidgetType(input.Type); err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
}
|
||||
if input.Title != "" {
|
||||
title, err := validation.Name(input.Title)
|
||||
if err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
input.Title = title
|
||||
} else if requireType {
|
||||
return store.WidgetInput{}, errors.New("title is required")
|
||||
}
|
||||
if len(input.Config) == 0 {
|
||||
input.Config = json.RawMessage(`{}`)
|
||||
}
|
||||
if !json.Valid(input.Config) {
|
||||
return store.WidgetInput{}, errors.New("config must be valid JSON")
|
||||
}
|
||||
if input.Type != "" {
|
||||
if err := ValidateWidgetConfig(input.Type, input.Config); err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func NormalizeWidgetPatch(current store.WidgetInstance, input store.WidgetInput) (store.WidgetInput, error) {
|
||||
widgetType := input.Type
|
||||
if widgetType == "" {
|
||||
widgetType = current.Type
|
||||
}
|
||||
title := input.Title
|
||||
if title == "" {
|
||||
title = current.Title
|
||||
}
|
||||
config := input.Config
|
||||
if len(config) == 0 {
|
||||
config = current.Config
|
||||
}
|
||||
normalized, err := NormalizeWidget(store.WidgetInput{
|
||||
Type: widgetType,
|
||||
Title: title,
|
||||
Enabled: input.Enabled,
|
||||
Config: config,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func ValidateWidgetConfig(widgetType string, raw json.RawMessage) error {
|
||||
tmpl, ok := widgets.GetTemplate(widgetType)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported widget type %q", widgetType)
|
||||
}
|
||||
if tmpl.Validate != nil {
|
||||
return tmpl.Validate(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MaskWidget(widget store.WidgetInstance) store.WidgetInstance {
|
||||
if (widget.Type != "pihole" && widget.Type != "memos") || len(widget.Config) == 0 {
|
||||
return widget
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(widget.Config, &cfg); err != nil {
|
||||
widget.Config = json.RawMessage(`{}`)
|
||||
return widget
|
||||
}
|
||||
if _, ok := cfg["apiToken"]; ok {
|
||||
cfg["apiToken"] = "********"
|
||||
}
|
||||
masked, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
widget.Config = json.RawMessage(`{}`)
|
||||
return widget
|
||||
}
|
||||
widget.Config = masked
|
||||
return widget
|
||||
}
|
||||
|
||||
func MaskWidgets(widgets []store.WidgetInstance) []store.WidgetInstance {
|
||||
for i := range widgets {
|
||||
widgets[i] = MaskWidget(widgets[i])
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dash/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestNormalizeServicePrimaryFallback(t *testing.T) {
|
||||
input := store.ServiceInput{
|
||||
Name: " Router ",
|
||||
URLs: []store.ServiceURLInput{
|
||||
{Label: "local", Kind: "local", URL: "http://router.local"},
|
||||
},
|
||||
}
|
||||
got, err := NormalizeService(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "Router" {
|
||||
t.Fatalf("name = %q", got.Name)
|
||||
}
|
||||
if !got.URLs[0].IsPrimary {
|
||||
t.Fatal("first URL not made primary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServiceRejectsTwoPrimary(t *testing.T) {
|
||||
_, err := NormalizeService(store.ServiceInput{
|
||||
Name: "Router",
|
||||
URLs: []store.ServiceURLInput{
|
||||
{Label: "local", Kind: "local", URL: "http://router.local", IsPrimary: true},
|
||||
{Label: "wan", Kind: "external", URL: "https://router.example.com", IsPrimary: true},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("accepted two primary URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskWidget(t *testing.T) {
|
||||
widget := store.WidgetInstance{
|
||||
Type: "pihole",
|
||||
Config: json.RawMessage(`{"baseUrl":"http://pihole.local","apiToken":"secret"}`),
|
||||
}
|
||||
got := MaskWidget(widget)
|
||||
if strings.Contains(string(got.Config), "secret") {
|
||||
t.Fatalf("token leaked: %s", got.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWidgetPatchValidatesCurrentType(t *testing.T) {
|
||||
current := store.WidgetInstance{
|
||||
Type: "image",
|
||||
Title: "Photo",
|
||||
Config: json.RawMessage(`{"imageUrl":"https://example.com/a.png"}`),
|
||||
}
|
||||
_, err := NormalizeWidgetPatch(current, store.WidgetInput{
|
||||
Config: json.RawMessage(`{"imageUrl":"/relative.png"}`),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("accepted invalid image config without explicit type")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user