mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
Port of the domain-intelligence pipeline from the old Beszel fork, adapted to Dash's SQLite + no-PocketBase architecture: - internal/domain: RDAP via IANA bootstrap (cached), TCP WHOIS :43 fallback with multi-format parser, DNS (NS/MX/TXT/CNAME/A/AAAA), TLS leaf cert details, ip-api.com geolocation, provider detection (DNS/email/hosting/CA). HTML scraping, paid APIs, and exec-based whois deliberately dropped. - API: domains CRUD + synchronous refresh + domain_checks snapshot history; refresh runs inside the monitor sweep on interval_h. - UI: /domains + /domains/:id pages, add/edit dialog, domain board widget (favicon + expiry countdown), palette + nav entries, domainId picker in the widget dialog. - Test seam: Server.lookup injects the refresh; domain tests run hermetic against a stubbed result. Verified live: github.com resolves MarkMonitor expiry 2028-10, Route 53 DNS, GitHub Pages hosting, Sectigo cert with 69d left. Authored By: TDvorak <[email protected]> Generated with Devin (https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
200 lines
5.6 KiB
Go
200 lines
5.6 KiB
Go
// Package api wires the Gin router and /api handlers.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tdvorak/dash/internal/domain"
|
|
"github.com/tdvorak/dash/internal/widget"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Server holds handler dependencies: DB, icon dir, logger, caches.
|
|
type Server struct {
|
|
db *sql.DB
|
|
iconsDir string
|
|
log *zap.Logger
|
|
status *statusCache
|
|
widgets *widgetCache
|
|
// lookup performs domain refreshes; swappable so tests don't hit the
|
|
// real WHOIS/RDAP/DNS path.
|
|
lookup func(ctx context.Context, name string) *domain.Result
|
|
}
|
|
|
|
func newServer(logger *zap.Logger, db *sql.DB, iconsDir string) *Server {
|
|
s := &Server{
|
|
db: db,
|
|
iconsDir: iconsDir,
|
|
log: logger,
|
|
status: newStatusCache(60 * time.Second),
|
|
widgets: newWidgetCache(30 * time.Second),
|
|
}
|
|
s.lookup = domain.NewLookup().Run
|
|
widget.Register(&monitorWidget{db: db})
|
|
widget.Register(&domainWidget{db: db})
|
|
return s
|
|
}
|
|
|
|
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
|
|
// A non-nil ctx starts the monitor scheduler; nil keeps it off (tests).
|
|
func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
|
s := newServer(logger, db, iconsDir)
|
|
if ctx != nil {
|
|
go s.runScheduler(ctx.Done())
|
|
}
|
|
return s.routes()
|
|
}
|
|
|
|
func (s *Server) routes() *gin.Engine {
|
|
r := gin.New()
|
|
r.Use(accessLog(s.log), gin.Recovery())
|
|
|
|
v1 := r.Group("/api")
|
|
v1.GET("/healthz", s.healthz)
|
|
|
|
v1.GET("/sections", s.listSections)
|
|
v1.POST("/sections", s.createSection)
|
|
v1.PATCH("/sections/:id", s.updateSection)
|
|
v1.DELETE("/sections/:id", s.deleteSection)
|
|
v1.POST("/sections/reorder", s.reorderSection)
|
|
|
|
v1.POST("/items", s.createItem)
|
|
v1.PATCH("/items/:id", s.updateItem)
|
|
v1.DELETE("/items/:id", s.deleteItem)
|
|
v1.POST("/items/reorder", s.reorderItem)
|
|
v1.POST("/items/:id/icon", s.uploadIcon)
|
|
v1.GET("/icons/:file", s.getIcon)
|
|
|
|
v1.GET("/monitors", s.listMonitorsH)
|
|
v1.POST("/monitors", s.createMonitor)
|
|
v1.GET("/monitors/:id", s.getMonitor)
|
|
v1.PATCH("/monitors/:id", s.patchMonitor)
|
|
v1.DELETE("/monitors/:id", s.deleteMonitor)
|
|
v1.GET("/monitors/:id/heartbeats", s.monitorHeartbeats)
|
|
v1.POST("/monitors/:id/check", s.checkNow)
|
|
v1.Any("/push/:token", s.pushIngest)
|
|
|
|
v1.GET("/domains", s.listDomainsH)
|
|
v1.POST("/domains", s.createDomain)
|
|
v1.GET("/domains/:id", s.getDomain)
|
|
v1.PATCH("/domains/:id", s.patchDomain)
|
|
v1.DELETE("/domains/:id", s.deleteDomain)
|
|
v1.POST("/domains/:id/refresh", s.refreshDomainH)
|
|
v1.GET("/domains/:id/checks", s.domainChecks)
|
|
|
|
v1.GET("/status", s.getStatus)
|
|
v1.GET("/widgets/types", s.widgetTypes)
|
|
v1.GET("/widgets/:id/data", s.widgetData)
|
|
v1.GET("/settings", s.getSettings)
|
|
v1.PUT("/settings", s.putSettings)
|
|
v1.GET("/export", s.exportBoard)
|
|
v1.POST("/import", s.importBoard)
|
|
v1.POST("/import/external", s.importExternal)
|
|
|
|
return r
|
|
}
|
|
|
|
// ---------- shared types (mirror openapi.yaml) ----------
|
|
|
|
// URL is one launch target of an item (local, external, custom label).
|
|
type URL struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
Label string `json:"label"`
|
|
Position float64 `json:"position"`
|
|
}
|
|
|
|
// URLInput is a URL as accepted on create/update (id/position assigned).
|
|
type URLInput struct {
|
|
URL string `json:"url"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// Item is a board cell: a service tile today, a widget in Phase 2.
|
|
type Item struct {
|
|
ID string `json:"id"`
|
|
SectionID string `json:"sectionId"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon"`
|
|
Position float64 `json:"position"`
|
|
Config *json.RawMessage `json:"config"`
|
|
URLs []URL `json:"urls"`
|
|
}
|
|
|
|
// Section is a named, collapsible group of items.
|
|
type Section struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Position float64 `json:"position"`
|
|
Collapsed bool `json:"collapsed"`
|
|
Items []Item `json:"items"`
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
func newID(prefix string) string {
|
|
b := make([]byte, 8)
|
|
_, _ = rand.Read(b)
|
|
return prefix + "_" + hex.EncodeToString(b)
|
|
}
|
|
|
|
// midpoint returns a fractional position between two optional neighbors.
|
|
func midpoint(before, after *float64) float64 {
|
|
switch {
|
|
case before == nil && after == nil:
|
|
return 1024
|
|
case before == nil:
|
|
return *after - 1024
|
|
case after == nil:
|
|
return *before + 1024
|
|
default:
|
|
return (*before + *after) / 2
|
|
}
|
|
}
|
|
|
|
func fail(c *gin.Context, code int, msg string) {
|
|
c.AbortWithStatusJSON(code, gin.H{"error": msg})
|
|
}
|
|
|
|
// validURL enforces http(s) launch targets — also keeps /api/status safe
|
|
// from file:// or internal-scheme abuse.
|
|
func validURL(raw string) bool {
|
|
u, err := url.Parse(raw)
|
|
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
|
}
|
|
|
|
func notFound(err error) bool { return errors.Is(err, sql.ErrNoRows) }
|
|
|
|
// healthz reports liveness and DB reachability.
|
|
func (s *Server) healthz(c *gin.Context) {
|
|
if err := s.db.PingContext(c.Request.Context()); err != nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
|
|
// accessLog is gin.Logger backed by zap: one structured line per request.
|
|
func accessLog(logger *zap.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
start := time.Now()
|
|
c.Next()
|
|
logger.Info("request",
|
|
zap.String("method", c.Request.Method),
|
|
zap.String("path", c.Request.URL.Path),
|
|
zap.Int("status", c.Writer.Status()),
|
|
zap.Duration("latency", time.Since(start)),
|
|
)
|
|
}
|
|
}
|