Files
Beszel/internal/hub/domains/api.go
T
Tomas Dvorak fe5c7eaa95
Build Docker images / Hub (push) Failing after 1m35s
feat(hub,site): enhance domain intelligence and monitor performance
Implement comprehensive domain data collection including provider detection (DNS, hosting, email, CA), HTTP headers, TLS certificate chains, and SEO metadata. Added PageSpeed Insights integration for monitors to track Core Web Vitals.

- **hub**:
  - Add provider detection logic for DNS, email, and hosting.
  - Expand `Domain` entity to include SEO, headers, certificates, and enhanced registration details.
  - Implement automated collection of TLD, WHOIS raw data, and host country codes.
  - Update scheduler to track changes in providers and security settings (privacy/transfer lock).
  - Add PageSpeed check endpoint to monitor API.
- **site**:
  - Update domain table and detail views to display new intelligence (providers, headers, SEO).
  - Implement PageSpeed metrics visualization with Core Web Vitals status indicators.
  - Add display options for provider information in the domain list.
- **db**:
  - Add migration for new domain collection fields.
2026-05-14 13:33:03 +02:00

876 lines
28 KiB
Go

package domains
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/domain"
"github.com/henrygd/beszel/internal/hub/domains/whois"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
// APIHandler handles domain API requests
type APIHandler struct {
app core.App
scheduler *Scheduler
}
// NewAPIHandler creates a new domain API handler
func NewAPIHandler(app core.App, scheduler *Scheduler) *APIHandler {
return &APIHandler{
app: app,
scheduler: scheduler,
}
}
// RegisterRoutes registers domain API routes
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api := se.Router.Group("/api/beszel/domains")
api.Bind(apis.RequireAuth())
api.GET("/", h.listDomains)
api.POST("/", h.createDomain)
api.POST("/lookup", h.lookupDomain)
api.GET("/{id}", h.getDomain)
api.PATCH("/{id}", h.updateDomain)
api.DELETE("/{id}", h.deleteDomain)
api.POST("/{id}/refresh", h.refreshDomain)
api.GET("/{id}/history", h.getDomainHistory)
api.GET("/{id}/stats", h.getDomainStats)
api.POST("/{id}/pause", h.pauseDomain)
api.POST("/{id}/resume", h.resumeDomain)
api.GET("/{id}/subdomains", h.getDomainSubdomains)
api.POST("/{id}/discover-subdomains", h.discoverSubdomains)
// Subdomain routes
api.DELETE("/subdomains/{subdomainId}", h.deleteSubdomain)
}
// listDomains lists all domains for the authenticated user
func (h *APIHandler) listDomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
records, err := h.app.FindAllRecords("domains",
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
)
if err != nil {
return e.InternalServerError("failed to fetch domains", err)
}
domains := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
domains = append(domains, h.recordToResponse(record))
}
return e.JSON(http.StatusOK, domains)
}
// lookupDomain performs a WHOIS lookup without saving
func (h *APIHandler) lookupDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
var req struct {
DomainName string `json:"domain_name"`
}
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
if req.DomainName == "" {
return e.BadRequestError("domain_name is required", nil)
}
// Clean domain
domainName := cleanDomain(req.DomainName)
// Perform lookup
lookupSvc := whois.NewLookupService("")
ctx := e.Request.Context()
domainData, err := lookupSvc.LookupDomain(ctx, domainName)
if err != nil {
return e.InternalServerError("lookup failed", err)
}
return e.JSON(http.StatusOK, domainData)
}
// createDomain creates a new domain entry
func (h *APIHandler) createDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
var req struct {
DomainName string `json:"domain_name"`
AutoLookup bool `json:"auto_lookup"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
PurchasePrice float64 `json:"purchase_price"`
CurrentValue float64 `json:"current_value"`
RenewalCost float64 `json:"renewal_cost"`
AutoRenew bool `json:"auto_renew"`
AlertDaysBefore int `json:"alert_days_before"`
SSLAlertEnabled bool `json:"ssl_alert_enabled"`
SSLAlertDays int `json:"ssl_alert_days"`
MonitorType string `json:"monitor_type"`
NotifyOnExpiry bool `json:"notify_on_expiry"`
NotifyOnSSL bool `json:"notify_on_ssl_expiry"`
NotifyOnDNS bool `json:"notify_on_dns_change"`
NotifyOnReg bool `json:"notify_on_registrar_change"`
// Manual expiry override when WHOIS fails
ExpiryDate string `json:"expiry_date,omitempty"`
CreationDate string `json:"creation_date,omitempty"`
}
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
if req.DomainName == "" {
return e.BadRequestError("domain_name is required", nil)
}
// Clean domain
domainName := cleanDomain(req.DomainName)
// Check if domain already exists for this user
existing, _ := h.app.FindFirstRecordByFilter("domains",
"domain_name = {:domain} && user = {:user}",
dbx.Params{"domain": domainName, "user": authRecord.Id})
if existing != nil {
return e.BadRequestError("domain already exists", nil)
}
collection, err := h.app.FindCollectionByNameOrId("domains")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
// Set defaults
if req.AlertDaysBefore <= 0 {
req.AlertDaysBefore = 30
}
record := core.NewRecord(collection)
record.Set("domain_name", domainName)
record.Set("status", domain.DomainStatusUnknown)
record.Set("active", true)
record.Set("tags", req.Tags)
record.Set("notes", req.Notes)
record.Set("purchase_price", req.PurchasePrice)
record.Set("current_value", req.CurrentValue)
record.Set("renewal_cost", req.RenewalCost)
record.Set("auto_renew", req.AutoRenew)
record.Set("alert_days_before", req.AlertDaysBefore)
record.Set("ssl_alert_enabled", req.SSLAlertEnabled)
record.Set("ssl_alert_days", req.SSLAlertDays)
record.Set("monitor_type", req.MonitorType)
record.Set("notify_on_expiry", req.NotifyOnExpiry)
record.Set("notify_on_ssl_expiry", req.NotifyOnSSL)
record.Set("notify_on_dns_change", req.NotifyOnDNS)
record.Set("notify_on_registrar_change", req.NotifyOnReg)
record.Set("user", authRecord.Id)
// Auto-lookup if requested
lookupHadExpiry := false
if req.AutoLookup {
lookupSvc := whois.NewLookupService("")
ctx := e.Request.Context()
domainData, err := lookupSvc.LookupDomain(ctx, domainName)
if err == nil && domainData != nil {
h.applyLookupData(record, domainData)
// Calculate status based on lookup results
status := domain.DomainStatusUnknown
if domainData.ExpiryDate != nil {
lookupHadExpiry = true
daysUntil := int(time.Until(*domainData.ExpiryDate).Hours() / 24)
if daysUntil < 0 {
status = domain.DomainStatusExpired
} else if daysUntil <= req.AlertDaysBefore {
status = domain.DomainStatusExpiring
} else {
status = domain.DomainStatusActive
}
} else if len(domainData.IPv4Addresses) > 0 || len(domainData.IPv6Addresses) > 0 || len(domainData.NameServers) > 0 {
// DNS resolves means the domain is active and functioning
status = domain.DomainStatusActive
}
record.Set("status", status)
}
}
// Apply manual expiry/creation dates if WHOIS didn't find them
if !lookupHadExpiry {
if req.ExpiryDate != "" {
if t, err := time.Parse("2006-01-02", req.ExpiryDate); err == nil {
record.Set("expiry_date", t)
// Recalculate status with manual expiry
daysUntil := int(time.Until(t).Hours() / 24)
status := domain.DomainStatusActive
if daysUntil < 0 {
status = domain.DomainStatusExpired
} else if daysUntil <= req.AlertDaysBefore {
status = domain.DomainStatusExpiring
}
record.Set("status", status)
}
}
if req.CreationDate != "" {
if t, err := time.Parse("2006-01-02", req.CreationDate); err == nil {
record.Set("creation_date", t)
}
}
}
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to create domain", err)
}
return e.JSON(http.StatusCreated, h.recordToResponse(record))
}
// getDomain gets a single domain
func (h *APIHandler) getDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
return e.JSON(http.StatusOK, h.recordToResponse(record))
}
// updateDomain updates a domain
func (h *APIHandler) updateDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
var req map[string]interface{}
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
// Update allowed fields
if tags, ok := req["tags"]; ok {
record.Set("tags", tags)
}
if notes, ok := req["notes"]; ok {
record.Set("notes", notes)
}
if price, ok := req["purchase_price"]; ok {
record.Set("purchase_price", price)
}
if value, ok := req["current_value"]; ok {
record.Set("current_value", value)
}
if renewal, ok := req["renewal_cost"]; ok {
record.Set("renewal_cost", renewal)
}
if autoRenew, ok := req["auto_renew"]; ok {
record.Set("auto_renew", autoRenew)
}
if active, ok := req["active"]; ok {
record.Set("active", active)
}
if alertDays, ok := req["alert_days_before"]; ok {
record.Set("alert_days_before", alertDays)
}
if sslAlert, ok := req["ssl_alert_enabled"]; ok {
record.Set("ssl_alert_enabled", sslAlert)
}
for _, field := range []string{
"ssl_alert_days",
"monitor_type",
"notify_on_expiry",
"notify_on_ssl_expiry",
"notify_on_dns_change",
"notify_on_registrar_change",
"notify_on_value_change",
"value_change_threshold",
"quiet_hours_enabled",
"quiet_hours_start",
"quiet_hours_end",
} {
if value, ok := req[field]; ok {
record.Set(field, value)
}
}
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to update domain", err)
}
return e.JSON(http.StatusOK, h.recordToResponse(record))
}
// deleteDomain deletes a domain
func (h *APIHandler) deleteDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
if err := h.app.Delete(record); err != nil {
return e.InternalServerError("failed to delete domain", err)
}
return e.NoContent(http.StatusNoContent)
}
// refreshDomain triggers a manual refresh
func (h *APIHandler) refreshDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
if h.scheduler != nil {
if err := h.scheduler.RefreshDomain(id); err != nil {
return e.InternalServerError("failed to refresh domain", err)
}
}
updatedRecord, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.InternalServerError("failed to fetch refreshed domain", err)
}
return e.JSON(http.StatusOK, h.recordToResponse(updatedRecord))
}
// getDomainHistory gets the change history for a domain
func (h *APIHandler) getDomainHistory(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
// Fetch history
records, err := h.app.FindAllRecords("domain_history",
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": id}),
)
if err != nil {
return e.InternalServerError("failed to fetch history", err)
}
history := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
history = append(history, map[string]interface{}{
"id": record.Id,
"change_type": record.GetString("change_type"),
"field_name": record.GetString("field_name"),
"old_value": record.GetString("old_value"),
"new_value": record.GetString("new_value"),
"created_at": record.GetDateTime("created_at").String(),
})
}
return e.JSON(http.StatusOK, history)
}
// getDomainStats gets domain health statistics
func (h *APIHandler) getDomainStats(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
// Calculate stats from domain history
stats := h.calculateDomainStats(id)
return e.JSON(http.StatusOK, stats)
}
// calculateDomainStats calculates health statistics from domain history
func (h *APIHandler) calculateDomainStats(domainID string) map[string]interface{} {
// Get history for the last 30 days
since := time.Now().AddDate(0, 0, -30)
records, _ := h.app.FindRecordsByFilter(
"domain_history",
"domain = {:domain} && created_at >= {:since}",
"-created_at",
0, 0,
dbx.Params{
"domain": domainID,
"since": since.Format("2006-01-02 15:04:05"),
},
)
totalChanges := len(records)
expiryChanges := 0
sslChanges := 0
statusChanges := 0
for _, record := range records {
switch record.GetString("change_type") {
case "expiry":
expiryChanges++
case "ssl":
sslChanges++
case "status":
statusChanges++
}
}
// Get incidents count
incidentRecords, _ := h.app.FindRecordsByFilter(
"incidents",
"domain = {:domain}",
"-created",
0, 0,
dbx.Params{"domain": domainID},
)
return map[string]interface{}{
"total_changes": totalChanges,
"expiry_changes": expiryChanges,
"ssl_changes": sslChanges,
"status_changes": statusChanges,
"incidents_count": len(incidentRecords),
"period_days": 30,
}
}
// pauseDomain pauses domain monitoring
func (h *APIHandler) pauseDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
record.Set("active", false)
record.Set("status", "paused")
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to pause domain", err)
}
return e.JSON(http.StatusOK, h.recordToResponse(record))
}
// resumeDomain resumes domain monitoring
func (h *APIHandler) resumeDomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
record.Set("active", true)
// Reset status - scheduler will update on next check
record.Set("status", "unknown")
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to resume domain", err)
}
// Trigger immediate refresh
if h.scheduler != nil {
h.scheduler.RefreshDomain(id)
}
return e.JSON(http.StatusOK, h.recordToResponse(record))
}
// recordToResponse converts a record to API response
func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{} {
expiryDate := record.GetDateTime("expiry_date").Time()
sslValidTo := record.GetDateTime("ssl_valid_to").Time()
// Calculate days until expiry
daysUntilExpiry := -1
if !expiryDate.IsZero() {
daysUntilExpiry = int(time.Until(expiryDate).Hours() / 24)
}
sslDaysUntil := -1
if !sslValidTo.IsZero() {
sslDaysUntil = int(time.Until(sslValidTo).Hours() / 24)
}
resp := map[string]interface{}{
"id": record.Id,
"domain_name": record.GetString("domain_name"),
"status": record.GetString("status"),
"active": record.GetBool("active"),
"days_until_expiry": daysUntilExpiry,
"registrar_name": record.GetString("registrar_name"),
"registrar_id": record.GetString("registrar_id"),
"registrar_url": record.GetString("registrar_url"),
"registry_domain_id": record.GetString("registry_domain_id"),
"dnssec": record.GetString("dnssec"),
"name_servers": record.Get("name_servers"),
"mx_records": record.Get("mx_records"),
"txt_records": record.Get("txt_records"),
"ipv4_addresses": record.Get("ipv4_addresses"),
"ipv6_addresses": record.Get("ipv6_addresses"),
"ssl_issuer": record.GetString("ssl_issuer"),
"ssl_issuer_country": record.GetString("ssl_issuer_country"),
"ssl_subject": record.GetString("ssl_subject"),
"ssl_days_until": sslDaysUntil,
"ssl_fingerprint": record.GetString("ssl_fingerprint"),
"ssl_key_size": record.GetInt("ssl_key_size"),
"ssl_signature_algo": record.GetString("ssl_signature_algo"),
"host_country": record.GetString("host_country"),
"host_region": record.GetString("host_region"),
"host_city": record.GetString("host_city"),
"host_isp": record.GetString("host_isp"),
"host_org": record.GetString("host_org"),
"host_as": record.GetString("host_as"),
"host_lat": record.GetFloat("host_lat"),
"host_lon": record.GetFloat("host_lon"),
"purchase_price": record.GetFloat("purchase_price"),
"current_value": record.GetFloat("current_value"),
"renewal_cost": record.GetFloat("renewal_cost"),
"auto_renew": record.GetBool("auto_renew"),
"alert_days_before": record.GetInt("alert_days_before"),
"ssl_alert_enabled": record.GetBool("ssl_alert_enabled"),
"ssl_alert_days": record.GetInt("ssl_alert_days"),
"monitor_type": record.GetString("monitor_type"),
"notify_on_expiry": record.GetBool("notify_on_expiry"),
"notify_on_ssl_expiry": record.GetBool("notify_on_ssl_expiry"),
"notify_on_dns_change": record.GetBool("notify_on_dns_change"),
"notify_on_registrar_change": record.GetBool("notify_on_registrar_change"),
"notify_on_value_change": record.GetBool("notify_on_value_change"),
"value_change_threshold": record.GetFloat("value_change_threshold"),
"quiet_hours_enabled": record.GetBool("quiet_hours_enabled"),
"quiet_hours_start": record.GetString("quiet_hours_start"),
"quiet_hours_end": record.GetString("quiet_hours_end"),
"registrant_name": record.GetString("registrant_name"),
"registrant_org": record.GetString("registrant_org"),
"registrant_country": record.GetString("registrant_country"),
"registrant_city": record.GetString("registrant_city"),
"registrant_state": record.GetString("registrant_state"),
"abuse_email": record.GetString("abuse_email"),
"abuse_phone": record.GetString("abuse_phone"),
"dns_provider": record.GetString("dns_provider"),
"hosting_provider": record.GetString("hosting_provider"),
"email_provider": record.GetString("email_provider"),
"ca_provider": record.GetString("ca_provider"),
"headers": record.Get("headers"),
"certificates": record.Get("certificates"),
"seo_meta": record.Get("seo_meta"),
"whois_raw": record.GetString("whois_raw"),
"privacy_enabled": record.GetBool("privacy_enabled"),
"transfer_lock": record.GetBool("transfer_lock"),
"tld": record.GetString("tld"),
"domain_statuses": record.Get("domain_statuses"),
"host_country_code": record.GetString("host_country_code"),
"tags": record.Get("tags"),
"notes": record.GetString("notes"),
"favicon_url": record.GetString("favicon_url"),
"created": record.GetDateTime("created").String(),
"updated": record.GetDateTime("updated").String(),
}
if !expiryDate.IsZero() {
resp["expiry_date"] = expiryDate.Format("2006-01-02T15:04:05Z")
}
creationDate := record.GetDateTime("creation_date").Time()
if !creationDate.IsZero() {
resp["creation_date"] = creationDate.Format("2006-01-02T15:04:05Z")
}
updatedDate := record.GetDateTime("updated_date").Time()
if !updatedDate.IsZero() {
resp["updated_date"] = updatedDate.Format("2006-01-02T15:04:05Z")
}
sslValidFrom := record.GetDateTime("ssl_valid_from").Time()
if !sslValidFrom.IsZero() {
resp["ssl_valid_from"] = sslValidFrom.Format("2006-01-02T15:04:05Z")
}
if !sslValidTo.IsZero() {
resp["ssl_valid_to"] = sslValidTo.Format("2006-01-02T15:04:05Z")
}
lastChecked := record.GetDateTime("last_checked").Time()
if !lastChecked.IsZero() {
resp["last_checked"] = lastChecked.Format("2006-01-02T15:04:05Z")
}
return resp
}
func (h *APIHandler) applyLookupData(record *core.Record, domainData *domain.Domain) {
if domainData.ExpiryDate != nil {
record.Set("expiry_date", *domainData.ExpiryDate)
}
if domainData.CreationDate != nil {
record.Set("creation_date", *domainData.CreationDate)
}
if domainData.UpdatedDate != nil {
record.Set("updated_date", *domainData.UpdatedDate)
}
record.Set("registrar_name", domainData.RegistrarName)
record.Set("registrar_id", domainData.RegistrarID)
record.Set("registrar_url", domainData.RegistrarURL)
record.Set("registry_domain_id", domainData.RegistryDomainID)
record.Set("dnssec", domainData.DNSSEC)
record.Set("name_servers", domainData.NameServers)
record.Set("mx_records", domainData.MXRecords)
record.Set("txt_records", domainData.TXTRecords)
record.Set("ipv4_addresses", domainData.IPv4Addresses)
record.Set("ipv6_addresses", domainData.IPv6Addresses)
record.Set("ssl_issuer", domainData.SSLIssuer)
record.Set("ssl_issuer_country", domainData.SSLIssuerCountry)
record.Set("ssl_subject", domainData.SSLSubject)
if domainData.SSLValidFrom != nil {
record.Set("ssl_valid_from", *domainData.SSLValidFrom)
}
if domainData.SSLValidTo != nil {
record.Set("ssl_valid_to", *domainData.SSLValidTo)
}
record.Set("ssl_fingerprint", domainData.SSLFingerprint)
record.Set("ssl_key_size", domainData.SSLKeySize)
record.Set("ssl_signature_algo", domainData.SSLSignatureAlgo)
record.Set("host_country", domainData.HostCountry)
record.Set("host_region", domainData.HostRegion)
record.Set("host_city", domainData.HostCity)
record.Set("host_isp", domainData.HostISP)
record.Set("host_org", domainData.HostOrg)
record.Set("host_as", domainData.HostAS)
record.Set("host_lat", domainData.HostLat)
record.Set("host_lon", domainData.HostLon)
record.Set("registrant_name", domainData.RegistrantName)
record.Set("registrant_org", domainData.RegistrantOrg)
record.Set("registrant_street", domainData.RegistrantStreet)
record.Set("registrant_city", domainData.RegistrantCity)
record.Set("registrant_state", domainData.RegistrantState)
record.Set("registrant_country", domainData.RegistrantCountry)
record.Set("registrant_postal", domainData.RegistrantPostal)
record.Set("abuse_email", domainData.AbuseEmail)
record.Set("abuse_phone", domainData.AbusePhone)
record.Set("dns_provider", domainData.DNSProvider)
record.Set("hosting_provider", domainData.HostingProvider)
record.Set("email_provider", domainData.EmailProvider)
record.Set("ca_provider", domainData.CAProvider)
if len(domainData.Headers) > 0 {
record.Set("headers", domainData.Headers)
}
if len(domainData.Certificates) > 0 {
record.Set("certificates", domainData.Certificates)
}
if domainData.SEOMeta != nil {
record.Set("seo_meta", domainData.SEOMeta)
}
record.Set("whois_raw", domainData.WHOISRaw)
record.Set("privacy_enabled", domainData.PrivacyEnabled)
record.Set("transfer_lock", domainData.TransferLock)
record.Set("tld", domainData.TLD)
if len(domainData.DomainStatuses) > 0 {
record.Set("domain_statuses", domainData.DomainStatuses)
}
record.Set("host_country_code", domainData.HostCountryCode)
record.Set("favicon_url", domainData.FaviconURL)
record.Set("last_checked", time.Now())
}
// cleanDomain cleans and normalizes a domain name
func cleanDomain(domain string) string {
// Remove protocol
domain = strings.TrimPrefix(domain, "https://")
domain = strings.TrimPrefix(domain, "http://")
// Remove www prefix
domain = strings.TrimPrefix(domain, "www.")
// Remove path and query
if idx := strings.IndexAny(domain, "/?#"); idx != -1 {
domain = domain[:idx]
}
// Remove port
if idx := strings.Index(domain, ":"); idx != -1 {
domain = domain[:idx]
}
return strings.ToLower(strings.TrimSpace(domain))
}
// getDomainSubdomains returns all subdomains for a domain
func (h *APIHandler) getDomainSubdomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
domainID := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", domainID)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
records, err := h.app.FindAllRecords("subdomains",
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": domainID}),
)
if err != nil {
return e.InternalServerError("failed to fetch subdomains", err)
}
subdomains := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
subdomains = append(subdomains, map[string]interface{}{
"id": record.Id,
"domain": record.GetString("domain"),
"subdomain_name": record.GetString("subdomain_name"),
"full_domain": record.GetString("full_domain"),
"status": record.GetString("status"),
"ip_addresses": record.GetString("ip_addresses"),
"http_status": record.GetInt("http_status"),
"server_header": record.GetString("server_header"),
"discovery_source": record.GetString("discovery_source"),
"last_checked": record.GetDateTime("last_checked").Time(),
"created": record.GetDateTime("created").Time(),
"updated": record.GetDateTime("updated").Time(),
})
}
return e.JSON(http.StatusOK, subdomains)
}
// discoverSubdomains triggers subdomain discovery for a domain
func (h *APIHandler) discoverSubdomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
domainID := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", domainID)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
// Trigger discovery asynchronously
go func() {
domainName := domain.GetString("domain_name")
userID := authRecord.Id
h.scheduler.discoverSubdomainsEnhanced(domain, domainName, userID)
}()
return e.JSON(http.StatusOK, map[string]string{"status": "discovery_started"})
}
// deleteSubdomain deletes a subdomain
func (h *APIHandler) deleteSubdomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
subdomainID := e.Request.PathValue("subdomainId")
// Get subdomain
subdomain, err := h.app.FindRecordById("subdomains", subdomainID)
if err != nil {
return e.NotFoundError("subdomain not found", err)
}
// Verify ownership
if subdomain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
if err := h.app.Delete(subdomain); err != nil {
return e.InternalServerError("failed to delete subdomain", err)
}
return e.NoContent(http.StatusNoContent)
}