Compare commits

..

25 commits

Author SHA1 Message Date
d6e846e940 notification: wire notification system into app lifecycle
All checks were successful
continuous-integration/drone/push Build is passing
Configure notification senders (email, webhook, UnifiedPush) and
dispatcher in initUsecases(). Connect the execution callback from the
checker engine to the dispatcher. Pass notification dependencies
through to the API router.

Add NotificationRetentionDays config option (default 90 days).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:55:04 +07:00
de627353f6 notification: add API endpoints for channels, preferences, history, and acknowledgement
New endpoints under /api/notifications:
- CRUD for notification channels (email, webhook, UnifiedPush)
- CRUD for notification preferences (global, per-domain, per-service)
- Notification history listing
- Test notification endpoint

Acknowledgement endpoints added to scoped checker routes:
- POST /api/domains/:domain/checkers/:checkerId/acknowledge
- DELETE /api/domains/:domain/checkers/:checkerId/acknowledge

Thread NotificationController through route declarations for scoped
checker routes (domain and service level).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
53ae816523 checker: add execution callback for notification integration
Add ExecutionCallbackSetter interface and onComplete field to the
checker engine. After a successful execution, the callback is fired
asynchronously so it never blocks the checker pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
abd660ffb1 notification: add dispatcher with state tracking and acknowledgement
The Dispatcher is the core notification logic: it receives execution
callbacks, detects status transitions via persisted NotificationState,
resolves user preferences by specificity (service > domain > global),
respects quiet hours, and dispatches through configured channels.

Acknowledgement support allows users to suppress repeat notifications
until the next state change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
c3b5d3a97c notification: add channel senders for email, webhook, and UnifiedPush
Implement ChannelSender interface with three backends:
- EmailSender: reuses existing Mailer, sends Markdown-formatted alerts
- WebhookSender: HTTP POST with JSON payload and optional HMAC signature
- UnifiedPushSender: HTTP POST following the UnifiedPush protocol

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
73fb32e359 notification: add storage interfaces and KV implementations
Introduce NotificationChannelStorage, NotificationPreferenceStorage,
NotificationStateStorage, and NotificationRecordStorage interfaces
with KV-based implementations. Extend the Storage composite interface
and add migration 10 (no-op for KV prefix-based storage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
9f98b780ca notification: add notification models and error sentinels
Add NotificationChannel, NotificationPreference, NotificationState,
and NotificationRecord models for the upcoming notification system.
These models support email, webhook, and UnifiedPush channels with
per-user/domain/service preference scoping and state deduplication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
9472e7fe2e log: render happydns.Identifier values via .String() in log messages
Some checks are pending
continuous-integration/drone/push Build is pending
2026-04-25 21:50:57 +07:00
c411d6b4ea Handle 2 edge cases in database migration 2026-04-25 21:50:57 +07:00
47fd9cd066 tidy go mod 2026-04-25 21:50:57 +07:00
2a37c2db43 fix(deps): update module github.com/oracle/nosql-go-sdk to v1.4.8
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-22 20:11:35 +00:00
99f53084fb scheduler: extract serviceCheckerApplies helper
All checks were successful
continuous-integration/drone/push Build is passing
The predicate guarding service-checker auto-scheduling was duplicated
across buildQueue and two sites in NotifyDomainChange. Pull it into a
single helper so the rule lives in one place.
2026-04-22 16:09:13 +07:00
cd957a7667 scheduler: only auto-schedule service checkers with LimitToServices
Service-level checkers without LimitToServices no longer get enqueued
for every matching service: they must be activated explicitly via a
CheckPlan. Domain checkers and service checkers that declare a
LimitToServices whitelist keep their previous auto-discovery behavior.
2026-04-22 16:08:27 +07:00
fa7700355a backup: include core checker entities in backup/restore
All checks were successful
continuous-integration/drone/push Build is passing
Extend the admin backup to cover checker configurations, plans,
evaluations and executions — previously these were stored but silently
lost on restore. Add RestoreX storage methods so primary records keep
their original Id and secondary indexes are rebuilt (Create* generates
new IDs, Update* requires an existing record to clean stale indexes).
2026-04-22 12:45:45 +07:00
da1eb33faf tidy: add drop_invalid flag to delete undecodable records
Thread a dropInvalid bool through every TidyUpUseCase method and
expose it as a drop_invalid query parameter on POST /tidy (default
true). When set, Tidy deletes records that fail to decode — e.g.
legacy executions and evaluations whose CheckState.Status was stored
as a string before the SDK switched it to int — instead of leaving
them stuck in the store to log on every iteration.

Also reset KVIterator.err on exhaustion so a prior decode failure
does not surface as a spurious iteration error.
2026-04-22 12:45:45 +07:00
9ef5717f5b chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-20 00:12:07 +00:00
0bee7695bc web: sort sessions by last used date in SessionsManager
All checks were successful
continuous-integration/drone/push Build is passing
Fixes: https://feedback.happydomain.org/posts/23/current-connection-sorted-list
2026-04-17 18:04:04 +07:00
a4f3595142 Use nodeJS workspace
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-17 12:58:17 +07:00
63bf11f1f5 CI: Generate a NOTICE file to keep dependancies licenses 2026-04-17 12:38:10 +07:00
a3aa74a7bf CI: Fix path to files to deploy with release
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-04-17 11:17:56 +07:00
66d420b737 Fix WHOIS lookup not detecting non-existent .com domains
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
The whoisparser library does not return ErrNotFoundDomain for Verisign
"No match" responses — it parses them into a result with an empty
Domain field. Add a post-parse check to detect this case and return
ErrDomainDoesNotExist.
2026-04-17 09:55:39 +07:00
4f20a2ff06 Add Prometheus export documentation
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2026-04-16 17:08:05 +07:00
57de739f80 web: add Prometheus metrics URL link to checker config page 2026-04-16 17:08:05 +07:00
e857b1fb99 web-admin: wire dashboard to /metrics with collapsible details
Replaces the three REST count calls with a single Prometheus scrape that
auto-refreshes every 15s, surfaces queue/worker/in-flight/RSS/version/uptime
as featured cards, and tucks counters and Go runtime stats under a
"Show more metrics" Collapse.
2026-04-16 17:08:05 +07:00
504660367e checkers: add filter predicate to ListExecutionsBy* storage methods
Metrics endpoints now skip incomplete/planned executions by passing a
`doneExecution` filter so only fully-evaluated runs contribute to the
Prometheus output.
2026-04-16 17:08:01 +07:00
43 changed files with 695 additions and 357 deletions

14
.drone-notice.tpl Normal file
View file

@ -0,0 +1,14 @@
happyDomain
===========
This product bundles third-party components under the following licenses.
The original license texts are reproduced in full below.
{{ range . }}
--------------------------------------------------------------------------------
Module: {{ .Name }}
License: {{ .LicenseName }}
{{ with .LicenseURL }}Source: {{ . }}
{{ end }}
{{ .LicenseText }}
{{ end }}

View file

@ -28,8 +28,8 @@ steps:
commands:
- apk --no-cache add tar
- yarn config set network-timeout 100000
- yarn --cwd web install
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./web/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz .
- yarn install
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./node_modules/.cache --exclude=./web/node_modules/.cache --exclude=./web-admin/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz ./package.json ./package-lock.json ./.npmrc ./node_modules/ ./web/ ./web-admin/
- mkdir deploy
- mv /dev/shm/happydomain-src.tar.gz deploy
- yarn --cwd web --offline run svelte-kit sync && yarn --cwd web --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-base/client.gen.ts
@ -37,14 +37,25 @@ steps:
- yarn --cwd web-admin --offline run svelte-kit sync && yarn --cwd web-admin --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-admin/client.gen.ts
- yarn --cwd web-admin --offline build
- name: frontend NOTICE
image: node:24-alpine
commands:
- npx --yes generate-license-file@3 --input web/package.json --output deploy/NOTICE.web --ci
- npx --yes generate-license-file@3 --input web-admin/package.json --output deploy/NOTICE.web-admin --ci
- for f in deploy/NOTICE.web deploy/NOTICE.web-admin; do { echo '------'; tail -n +3 "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"; done
when:
event:
- tag
- name: backend-commit
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- go build -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
GOFLAGS: "-tags=netgo,swagger,web"
when:
event:
exclude:
@ -54,10 +65,16 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- go build -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
# Generate NOTICE file (merging Go + frontend)
- go install github.com/google/go-licenses@v1.6.0
- go-licenses report ./cmd/happyDomain --template .drone-notice.tpl > deploy/NOTICE.go
- cat deploy/NOTICE.go deploy/NOTICE.web deploy/NOTICE.web-admin > deploy/NOTICE
- rm deploy/NOTICE.go deploy/NOTICE.web deploy/NOTICE.web-admin
environment:
CGO_ENABLED: 0
GOFLAGS: "-tags=netgo,swagger,web"
when:
event:
- tag
@ -154,11 +171,7 @@ steps:
from_secret: git_nemunaire_token
base_url: https://git.nemunai.re
draft: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
files: deploy/*
when:
event:
- tag
@ -170,11 +183,7 @@ steps:
from_secret: codeberg_token
base_url: https://codeberg.org
draft: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
files: deploy/*
when:
event:
- tag
@ -186,11 +195,7 @@ steps:
from_secret: github_release_token
draft: true
github_url: https://github.com
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
files: deploy/*
when:
event:
- tag
@ -232,8 +237,8 @@ steps:
- name: frontend
image: node:24-alpine
commands:
- cd web
- npm install --network-timeout=100000
- cd web
- npx svelte-kit sync && npm run generate:api
- npm test
- npm run build
@ -362,9 +367,7 @@ steps:
base_url: https://git.nemunai.re
draft: true
prerelease: true
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
files: deploy/*
when:
event:
- tag
@ -377,9 +380,7 @@ steps:
base_url: https://codeberg.org
draft: true
prerelease: true
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
files: deploy/*
when:
event:
- tag
@ -392,9 +393,7 @@ steps:
draft: true
prerelease: true
github_url: https://github.com
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
files: deploy/*
when:
event:
- tag

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
./happydomain
./happyDomain
node_modules

View file

2
go.mod
View file

@ -27,7 +27,7 @@ require (
github.com/libdns/libdns v1.1.1
github.com/miekg/dns v1.1.72
github.com/mileusna/useragent v1.3.5
github.com/oracle/nosql-go-sdk v1.4.7
github.com/oracle/nosql-go-sdk v1.4.8
github.com/ovh/go-ovh v1.9.0
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
github.com/swaggo/files v1.0.1

11
go.sum
View file

@ -135,6 +135,10 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
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=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@ -472,8 +476,8 @@ github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
github.com/oracle/nosql-go-sdk v1.4.8 h1:eMzz+yNLHvB0GCPAWxe0qYttBJF7Fh0Aup+zectpgc4=
github.com/oracle/nosql-go-sdk v1.4.8/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
@ -629,6 +633,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
@ -648,7 +654,6 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=

View file

@ -120,6 +120,46 @@ func (bc *BackupController) DoBackup() (ret happydns.Backup) {
}
}
// Checker configurations (positional, one entry per (checker, user?, domain?, service?)).
if cfgIter, err := bc.store.ListAllCheckerConfigurations(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckerConfigurations: %s", err.Error()))
} else {
defer cfgIter.Close()
for cfgIter.Next() {
ret.CheckerConfigurations = append(ret.CheckerConfigurations, cfgIter.Item())
}
}
// Check plans.
if planIter, err := bc.store.ListAllCheckPlans(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckPlans: %s", err.Error()))
} else {
defer planIter.Close()
for planIter.Next() {
ret.CheckPlans = append(ret.CheckPlans, planIter.Item())
}
}
// Check evaluations.
if evalIter, err := bc.store.ListAllEvaluations(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckEvaluations: %s", err.Error()))
} else {
defer evalIter.Close()
for evalIter.Next() {
ret.CheckEvaluations = append(ret.CheckEvaluations, evalIter.Item())
}
}
// Executions.
if execIter, err := bc.store.ListAllExecutions(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve Executions: %s", err.Error()))
} else {
defer execIter.Close()
for execIter.Next() {
ret.Executions = append(ret.Executions, execIter.Item())
}
}
return
}
@ -175,6 +215,29 @@ func (bc *BackupController) DoRestore(backup *happydns.Backup) (errs error) {
errs = errors.Join(errs, bc.store.UpdateSession(session))
}
// Checker configurations.
for _, cfg := range backup.CheckerConfigurations {
if cfg == nil {
continue
}
errs = errors.Join(errs, bc.store.UpdateCheckerConfiguration(cfg.CheckName, cfg.UserId, cfg.DomainId, cfg.ServiceId, cfg.Options))
}
// Check plans.
for _, plan := range backup.CheckPlans {
errs = errors.Join(errs, bc.store.RestoreCheckPlan(plan))
}
// Check evaluations (reference plans, restored above).
for _, eval := range backup.CheckEvaluations {
errs = errors.Join(errs, bc.store.RestoreEvaluation(eval))
}
// Executions.
for _, exec := range backup.Executions {
errs = errors.Join(errs, bc.store.RestoreExecution(exec))
}
return
}

View file

@ -22,6 +22,9 @@
package controller
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
@ -41,14 +44,25 @@ func NewTidyController(tidyUpService happydns.TidyUpUseCase) *TidyController {
//
// @Summary Tidy up the database
// @Schemes
// @Description Performs cleanup and maintenance operations on the database, removing orphaned records and optimizing storage.
// @Description Performs cleanup and maintenance operations on the database, removing orphaned records and optimizing storage. When drop_invalid is true (default), records that fail to decode (e.g. after a schema change) are deleted; set it to false to only log them.
// @Tags admin
// @Accept json
// @Produce json
// @Param drop_invalid query bool false "Delete records that fail to decode instead of only logging (default true)"
// @Security securitydefinitions.basic
// @Success 200 {boolean} bool
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /tidy [post]
func (tc *TidyController) TidyDB(c *gin.Context) {
happydns.ApiResponse(c, true, tc.tidyUpService.TidyAll())
dropInvalid := true
if v := c.Query("drop_invalid"); v != "" {
parsed, err := strconv.ParseBool(v)
if err != nil {
happydns.ApiResponse(c, nil, fmt.Errorf("drop_invalid must be a boolean: %w", err))
return
}
dropInvalid = parsed
}
happydns.ApiResponse(c, true, tc.tidyUpService.TidyAll(dropInvalid))
}

View file

@ -244,7 +244,7 @@ func (cc *CheckerController) TriggerCheck(c *gin.Context) {
} else {
go func() {
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
log.Printf("async RunExecution error for checker %q execution %s: %v", cname, exec.Id.String(), err)
}
}()
c.JSON(http.StatusAccepted, exec)

View file

@ -75,7 +75,7 @@ func SameUserHandler(c *gin.Context) {
user := c.MustGet("user").(*happydns.User)
if !bytes.Equal(user.Id, myuser.Id) {
log.Printf("%s: tries to do action as %s (logged %s)", c.ClientIP(), myuser.Id, user.Id)
log.Printf("%s: tries to do action as %s (logged %s)", c.ClientIP(), myuser.Id.String(), user.Id.String())
c.AbortWithStatusJSON(http.StatusForbidden, happydns.ErrorResponse{Message: "Not authorized"})
return
}

View file

@ -559,6 +559,21 @@ func (s *instrumentedStorage) PutCachedObservation(target happydns.CheckTarget,
return s.inner.PutCachedObservation(target, key, entry)
}
func (s *instrumentedStorage) RestoreCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("restore", "check_plan")(&err)
return s.inner.RestoreCheckPlan(plan)
}
func (s *instrumentedStorage) RestoreEvaluation(eval *happydns.CheckEvaluation) (err error) {
defer observe("restore", "check_evaluation")(&err)
return s.inner.RestoreEvaluation(eval)
}
func (s *instrumentedStorage) RestoreExecution(exec *happydns.Execution) (err error) {
defer observe("restore", "execution")(&err)
return s.inner.RestoreExecution(exec)
}
func (s *instrumentedStorage) PutState(state *happydns.NotificationState) (err error) {
defer observe("put", "notification_state")(&err)
return s.inner.PutState(state)

View file

@ -106,6 +106,24 @@ func (s *KVStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error {
return nil
}
// RestoreEvaluation writes an evaluation at its existing Id and rebuilds
// its secondary indexes. Used by the backup restore path.
func (s *KVStorage) RestoreEvaluation(eval *happydns.CheckEvaluation) error {
if err := s.db.Put(fmt.Sprintf("chckeval|%s", eval.Id.String()), eval); err != nil {
return err
}
if eval.PlanID != nil {
indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
if err := s.db.Put(indexKey, true); err != nil {
return err
}
}
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
return s.db.Put(checkerIndexKey, true)
}
func (s *KVStorage) DeleteEvaluation(evalID happydns.Identifier) error {
// Load first to find plan ID for index cleanup.
eval, err := s.GetEvaluation(evalID)

View file

@ -138,6 +138,16 @@ func (s *KVStorage) putCheckPlanIndexes(plan *happydns.CheckPlan) error {
return nil
}
// RestoreCheckPlan writes a plan at its existing Id and (re)builds its
// secondary indexes. Used by the backup restore path, which must preserve
// the original identifier instead of generating a new one.
func (s *KVStorage) RestoreCheckPlan(plan *happydns.CheckPlan) error {
if err := s.db.Put(fmt.Sprintf("chckpln|%s", plan.Id.String()), plan); err != nil {
return err
}
return s.putCheckPlanIndexes(plan)
}
func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error {
plan, err := s.GetCheckPlan(planID)
if err != nil {

View file

@ -123,6 +123,40 @@ func (s *KVStorage) CreateExecution(exec *happydns.Execution) error {
return nil
}
// RestoreExecution writes an execution at its existing Id and rebuilds
// its secondary indexes. Used by the backup restore path.
func (s *KVStorage) RestoreExecution(exec *happydns.Execution) error {
if err := s.db.Put(fmt.Sprintf("chckexec|%s", exec.Id.String()), exec); err != nil {
return err
}
if exec.PlanID != nil {
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
if err := s.db.Put(indexKey, true); err != nil {
return err
}
}
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
if err := s.db.Put(checkerIndexKey, true); err != nil {
return err
}
if exec.Target.UserId != "" {
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
return err
}
}
if exec.Target.DomainId != "" {
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
return err
}
}
return nil
}
func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error {
// Load the old record so we can detect changed index keys.
old, err := s.GetExecution(exec.Id)

View file

@ -76,6 +76,7 @@ func (it *KVIterator[T]) NextWithError() bool {
}
return true
}
it.err = nil
return false
}

View file

@ -38,12 +38,12 @@ import (
// abstract.EMail
func explodeAbstractEMail(dn happydns.Subdomain, in *happydns.ServiceMessage) ([]*happydns.ServiceMessage, error) {
var val struct {
MX []map[string]any `json:"mx,omitempty"`
SPF map[string]any `json:"spf,omitempty"`
DKIM map[string]*svcs.DKIM `json:"dkim,omitempty"`
DMARC *svcs.DMARCFields `json:"dmarc,omitempty"`
MTA_STS *svcs.MTASTSFields `json:"mta_sts,omitempty"`
TLS_RPT *svcs.TLS_RPTField `json:"tls_rpt,omitempty"`
MX []map[string]any `json:"mx,omitempty"`
SPF map[string]any `json:"spf,omitempty"`
DKIM map[string]*svcs.DKIM `json:"dkim,omitempty"`
DMARC *svcs.DMARCFields `json:"dmarc,omitempty"`
MTA_STS *svcs.MTASTSFields `json:"mta_sts,omitempty"`
TLS_RPT *svcs.TLS_RPTField `json:"tls_rpt,omitempty"`
}
err := json.Unmarshal(in.Service, &val)
@ -815,7 +815,12 @@ func migrateFrom7(s *KVStorage) error {
return nil, err
}
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN CNAME %s", val["SubDomain"], helpers.DomainFQDN(val["Target"], "zZzZ.")))
var rr dns.RR
if strings.Contains(val["Target"], "IN\tCNAME") {
rr, err = dns.NewRR(val["Target"])
} else {
rr, err = dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN CNAME %s", val["SubDomain"], helpers.DomainFQDN(val["Target"], "zZzZ.")))
}
if err != nil {
return nil, err
}

View file

@ -22,6 +22,7 @@
package database
import (
"fmt"
"log"
)
@ -33,6 +34,15 @@ func migrateFrom9(s *KVStorage) (err error) {
for sessions.Next() {
session := sessions.Item()
if len(session.Id) != 103 {
err = sessions.DropItem()
if err != nil {
return fmt.Errorf("unable to drop invalid session: %s: %w", session.Id, err)
}
log.Printf("Drop invalid session identifier: %s", session.Id)
continue
}
err := s.UpdateSession(session)
if err != nil {
return err

View file

@ -374,6 +374,11 @@ func (s *planStore) UpdateCheckPlan(plan *happydns.CheckPlan) error {
return nil
}
func (s *planStore) RestoreCheckPlan(plan *happydns.CheckPlan) error {
s.plans[plan.Id.String()] = plan
return nil
}
func (s *planStore) DeleteCheckPlan(planID happydns.Identifier) error {
delete(s.plans, planID.String())
return nil

View file

@ -102,6 +102,7 @@ func (s *mockExecStore) GetExecution(happydns.Identifier) (*happydns.Execution,
}
func (s *mockExecStore) CreateExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) UpdateExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) RestoreExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) DeleteExecutionsByChecker(string, happydns.CheckTarget) error { return nil }
func (s *mockExecStore) TidyExecutionIndexes() error { return nil }
func (s *mockExecStore) ClearExecutions() error { return nil }
@ -492,6 +493,7 @@ func (s *mockEvalStore) GetLatestEvaluation(happydns.Identifier) (*happydns.Chec
return nil, nil
}
func (s *mockEvalStore) CreateEvaluation(*happydns.CheckEvaluation) error { return nil }
func (s *mockEvalStore) RestoreEvaluation(*happydns.CheckEvaluation) error { return nil }
func (s *mockEvalStore) DeleteEvaluationsByChecker(string, happydns.CheckTarget) error { return nil }
func (s *mockEvalStore) TidyEvaluationIndexes() error { return nil }
func (s *mockEvalStore) ClearEvaluations() error { return nil }

View file

@ -492,7 +492,7 @@ func (s *Scheduler) buildQueue() {
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid.String(), ServiceType: svc.Type}
for _, c := range serviceCheckers {
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, svc.Type) {
if !serviceCheckerApplies(c.def, svc.Type) {
continue
}
s.enqueueJob(c.id, c.def, svcTarget, disabledSet, planMap, lastRun)
@ -534,7 +534,7 @@ func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
}
if def.Availability.ApplyToService {
for _, svc := range services {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
if !serviceCheckerApplies(def, svc.Type) {
continue
}
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: svc.Id.String(), ServiceType: svc.Type}
@ -579,7 +579,7 @@ func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
if def.Availability.ApplyToService {
for _, svc := range services {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
if !serviceCheckerApplies(def, svc.Type) {
continue
}
sid := svc.Id
@ -627,10 +627,20 @@ func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
s.mu.Unlock()
if n > 0 {
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID.String(), n)
}
}
// serviceCheckerApplies reports whether a service-scoped checker should be
// auto-scheduled for the given service type. Auto-scheduling is restricted to
// checkers that explicitly declare the service type in LimitToServices; a
// checker with an empty LimitToServices must be activated manually via a
// CheckPlan.
func serviceCheckerApplies(def *happydns.CheckerDefinition, serviceType string) bool {
return len(def.Availability.LimitToServices) > 0 &&
slices.Contains(def.Availability.LimitToServices, serviceType)
}
// buildPlanIndex builds disabled and plan lookup maps from a slice of plans.
func buildPlanIndex(plans []*happydns.CheckPlan) (disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan) {
disabledSet = make(map[string]bool)
@ -731,7 +741,7 @@ func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.Serv
}
zone, err := s.zoneStore.GetZone(domain.ZoneHistory[idx])
if err != nil {
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx], domain.DomainName, err)
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx].String(), domain.DomainName, err)
continue
}
for _, svcs := range zone.Services {

View file

@ -121,8 +121,9 @@ func (s *mockPlanStore) CreateCheckPlan(plan *happydns.CheckPlan) error {
s.plans = append(s.plans, plan)
return nil
}
func (s *mockPlanStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { return nil }
func (s *mockPlanStore) DeleteCheckPlan(happydns.Identifier) error { return nil }
func (s *mockPlanStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { return nil }
func (s *mockPlanStore) RestoreCheckPlan(plan *happydns.CheckPlan) error { return nil }
func (s *mockPlanStore) DeleteCheckPlan(happydns.Identifier) error { return nil }
func (s *mockPlanStore) TidyCheckPlanIndexes() error { return nil }
func (s *mockPlanStore) ClearCheckPlans() error { return nil }

View file

@ -61,6 +61,7 @@ type CheckPlanStorage interface {
GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error)
CreateCheckPlan(plan *happydns.CheckPlan) error
UpdateCheckPlan(plan *happydns.CheckPlan) error
RestoreCheckPlan(plan *happydns.CheckPlan) error
DeleteCheckPlan(planID happydns.Identifier) error
TidyCheckPlanIndexes() error
ClearCheckPlans() error
@ -84,6 +85,7 @@ type CheckEvaluationStorage interface {
GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error)
GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error)
CreateEvaluation(eval *happydns.CheckEvaluation) error
RestoreEvaluation(eval *happydns.CheckEvaluation) error
DeleteEvaluation(evalID happydns.Identifier) error
DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error
TidyEvaluationIndexes() error
@ -100,6 +102,7 @@ type ExecutionStorage interface {
GetExecution(execID happydns.Identifier) (*happydns.Execution, error)
CreateExecution(exec *happydns.Execution) error
UpdateExecution(exec *happydns.Execution) error
RestoreExecution(exec *happydns.Execution) error
DeleteExecution(execID happydns.Identifier) error
DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error
TidyExecutionIndexes() error

View file

@ -209,7 +209,7 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
if len(domain.ZoneHistory) > 1 {
prevZone, prevErr := uc.zoneGetter.Get(domain.ZoneHistory[1])
if prevErr != nil {
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1], prevErr)
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1].String(), prevErr)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}

View file

@ -62,7 +62,7 @@ func (uc *ZoneImporterUsecase) Import(user *happydns.User, domain *happydns.Doma
if len(domain.ZoneHistory) > 0 {
prevZone, err := uc.zoneGetter.Get(domain.ZoneHistory[0])
if err != nil {
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0], err)
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0].String(), err)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}

View file

@ -40,8 +40,33 @@ func NewTidyUpUsecase(store storage.Storage) happydns.TidyUpUseCase {
}
}
func (tu *tidyUpUsecase) TidyAll() error {
for _, tidy := range []func() error{
// iterateTidy drives an iterator using NextWithError so Tidy can decide
// whether to delete undecodable records (via DropItem) or just log them.
// handle is only invoked for successfully decoded items.
func iterateTidy[T any](iter happydns.Iterator[T], dropInvalid bool, handle func(*T) error) error {
for iter.NextWithError() {
item := iter.Item()
if item == nil {
key := iter.Key()
log.Printf("KVIterator: error decoding item at key %q: %s", key, iter.Err())
if dropInvalid {
if err := iter.DropItem(); err != nil {
log.Printf("KVIterator: failed to delete invalid item at key %q: %s", key, err)
} else {
log.Printf("KVIterator: dropped invalid item at key %q", key)
}
}
continue
}
if err := handle(item); err != nil {
return err
}
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyAll(dropInvalid bool) error {
for _, tidy := range []func(bool) error{
tu.TidySessions,
tu.TidyAuthUsers,
tu.TidyUsers,
@ -56,24 +81,22 @@ func (tu *tidyUpUsecase) TidyAll() error {
tu.TidySnapshots,
tu.TidyObservationCache,
} {
if err := tidy(); err != nil {
if err := tidy(dropInvalid); err != nil {
return err
}
}
return nil
}
func (tu *tidyUpUsecase) TidyAuthUsers() error {
func (tu *tidyUpUsecase) TidyAuthUsers(dropInvalid bool) error {
iter, err := tu.store.ListAllAuthUsers()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
userAuth := iter.Item()
_, err = tu.store.GetUser(userAuth.Id)
return iterateTidy(iter, dropInvalid, func(userAuth *happydns.UserAuth) error {
_, err := tu.store.GetUser(userAuth.Id)
if errors.Is(err, happydns.ErrUserNotFound) && time.Since(userAuth.CreatedAt) > 24*time.Hour {
// Drop providers of unexistant users
log.Printf("Deleting orphan authuser (user %s not found): %v\n", userAuth.Id.String(), userAuth)
@ -81,21 +104,18 @@ func (tu *tidyUpUsecase) TidyAuthUsers() error {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
func (tu *tidyUpUsecase) TidyCheckEvaluations(dropInvalid bool) error {
iter, err := tu.store.ListAllEvaluations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
eval := iter.Item()
err = iterateTidy(iter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
drop := false
if eval.Target.UserId != "" {
@ -119,36 +139,34 @@ func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
}
if !drop && eval.PlanID != nil {
if _, err = tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
if _, err := tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
log.Printf("Deleting orphan check evaluation (plan %s not found): %s\n", eval.PlanID.String(), eval.Id.String())
drop = true
}
}
if drop {
if err = tu.store.DeleteEvaluation(eval.Id); err != nil {
if err := tu.store.DeleteEvaluation(eval.Id); err != nil {
return err
}
}
}
if err = iter.Err(); err != nil {
return nil
})
if err != nil {
return err
}
return tu.store.TidyEvaluationIndexes()
}
func (tu *tidyUpUsecase) TidyCheckPlans() error {
func (tu *tidyUpUsecase) TidyCheckPlans(dropInvalid bool) error {
iter, err := tu.store.ListAllCheckPlans()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
plan := iter.Item()
err = iterateTidy(iter, dropInvalid, func(plan *happydns.CheckPlan) error {
if plan.Target.UserId != "" {
userId, err := happydns.NewIdentifierFromString(plan.Target.UserId)
if err == nil {
@ -157,10 +175,7 @@ func (tu *tidyUpUsecase) TidyCheckPlans() error {
log.Printf("Deleting orphan check plan (user %s not found): %s\n", plan.Target.UserId, plan.Id.String())
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
}
}
}
@ -173,39 +188,31 @@ func (tu *tidyUpUsecase) TidyCheckPlans() error {
log.Printf("Deleting orphan check plan (domain %s not found): %s\n", plan.Target.DomainId, plan.Id.String())
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
}
}
}
}
if err := iter.Err(); err != nil {
return nil
})
if err != nil {
return err
}
return tu.store.TidyCheckPlanIndexes()
}
func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
func (tu *tidyUpUsecase) TidyCheckerConfigurations(dropInvalid bool) error {
iter, err := tu.store.ListAllCheckerConfigurations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
cfg := iter.Item()
return iterateTidy(iter, dropInvalid, func(cfg *happydns.CheckerOptionsPositional) error {
if cfg.UserId != nil {
if _, err = tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
if _, err := tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
log.Printf("Deleting orphan checker configuration (user %s not found): %s\n", cfg.UserId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
} else if err != nil {
return err
}
@ -215,10 +222,7 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
domain, err := tu.store.GetDomain(*cfg.DomainId)
if errors.Is(err, happydns.ErrDomainNotFound) {
log.Printf("Deleting orphan checker configuration (domain %s not found): %s\n", cfg.DomainId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
} else if err != nil {
return err
}
@ -253,28 +257,22 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
}
if !found {
log.Printf("Deleting orphan checker configuration (service %s not found in domain %s): %s\n", cfg.ServiceId.String(), cfg.DomainId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
}
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyExecutions() error {
func (tu *tidyUpUsecase) TidyExecutions(dropInvalid bool) error {
iter, err := tu.store.ListAllExecutions()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
exec := iter.Item()
err = iterateTidy(iter, dropInvalid, func(exec *happydns.Execution) error {
drop := false
if exec.Target.UserId != "" {
@ -298,58 +296,53 @@ func (tu *tidyUpUsecase) TidyExecutions() error {
}
if !drop && exec.PlanID != nil {
if _, err = tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
if _, err := tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
log.Printf("Deleting orphan execution (plan %s not found): %s\n", exec.PlanID.String(), exec.Id.String())
drop = true
}
}
if drop {
if err = tu.store.DeleteExecution(exec.Id); err != nil {
if err := tu.store.DeleteExecution(exec.Id); err != nil {
return err
}
}
}
if err = iter.Err(); err != nil {
return nil
})
if err != nil {
return err
}
return tu.store.TidyExecutionIndexes()
}
func (tu *tidyUpUsecase) TidyObservationCache() error {
func (tu *tidyUpUsecase) TidyObservationCache(dropInvalid bool) error {
iter, err := tu.store.ListAllCachedObservations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
entry := iter.Item()
if _, err = tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
return iterateTidy(iter, dropInvalid, func(entry *happydns.ObservationCacheEntry) error {
if _, err := tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
log.Printf("Deleting stale observation cache entry (snapshot %s not found)\n", entry.SnapshotID.String())
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyDomains() error {
func (tu *tidyUpUsecase) TidyDomains(dropInvalid bool) error {
iter, err := tu.store.ListAllDomains()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
domain := iter.Item()
if _, err = tu.store.GetUser(domain.Owner); errors.Is(err, happydns.ErrUserNotFound) {
return iterateTidy(iter, dropInvalid, func(domain *happydns.Domain) error {
if _, err := tu.store.GetUser(domain.Owner); errors.Is(err, happydns.ErrUserNotFound) {
// Drop domain of unexistant users
log.Printf("Deleting orphan domain (user %s not found): %v\n", domain.Owner.String(), domain)
if err = iter.DropItem(); err != nil {
@ -357,51 +350,45 @@ func (tu *tidyUpUsecase) TidyDomains() error {
}
}
if _, err = tu.store.GetProvider(domain.ProviderId); errors.Is(err, happydns.ErrProviderNotFound) {
if _, err := tu.store.GetProvider(domain.ProviderId); errors.Is(err, happydns.ErrProviderNotFound) {
// Drop domain of unexistant provider
log.Printf("Deleting orphan domain (provider %s not found): %v\n", domain.ProviderId.String(), domain)
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyDomainLogs() error {
func (tu *tidyUpUsecase) TidyDomainLogs(dropInvalid bool) error {
iter, err := tu.store.ListAllDomainLogs()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
l := iter.Item()
if _, err = tu.store.GetDomain(l.DomainId); errors.Is(err, happydns.ErrDomainNotFound) {
return iterateTidy(iter, dropInvalid, func(l *happydns.DomainLogWithDomainId) error {
if _, err := tu.store.GetDomain(l.DomainId); errors.Is(err, happydns.ErrDomainNotFound) {
// Drop domain of unexistant provider
log.Printf("Deleting orphan domain log (domain %s not found): %v\n", l.DomainId.String(), l)
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyProviders() error {
func (tu *tidyUpUsecase) TidyProviders(dropInvalid bool) error {
iter, err := tu.store.ListAllProviders()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
prvd := iter.Item()
_, err = tu.store.GetUser(prvd.Owner)
return iterateTidy(iter, dropInvalid, func(prvd *happydns.ProviderMessage) error {
_, err := tu.store.GetUser(prvd.Owner)
if errors.Is(err, happydns.ErrUserNotFound) {
// Drop providers of unexistant users
log.Printf("Deleting orphan provider (user %s not found): %v\n", prvd.Owner.String(), prvd)
@ -409,22 +396,19 @@ func (tu *tidyUpUsecase) TidyProviders() error {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidySessions() error {
func (tu *tidyUpUsecase) TidySessions(dropInvalid bool) error {
iter, err := tu.store.ListAllSessions()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
session := iter.Item()
_, err = tu.store.GetUser(session.IdUser)
return iterateTidy(iter, dropInvalid, func(session *happydns.Session) error {
_, err := tu.store.GetUser(session.IdUser)
if errors.Is(err, happydns.ErrUserNotFound) {
// Drop session from unexistant users
log.Printf("Deleting orphan session (user %s not found): %v\n", session.IdUser.String(), session)
@ -432,12 +416,11 @@ func (tu *tidyUpUsecase) TidySessions() error {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidySnapshots() error {
func (tu *tidyUpUsecase) TidySnapshots(dropInvalid bool) error {
// Collect all snapshot IDs referenced by evaluations.
evalIter, err := tu.store.ListAllEvaluations()
if err != nil {
@ -446,13 +429,12 @@ func (tu *tidyUpUsecase) TidySnapshots() error {
defer evalIter.Close()
referencedSnapshots := make(map[string]struct{})
for evalIter.Next() {
eval := evalIter.Item()
if err = iterateTidy(evalIter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
if !eval.SnapshotID.IsEmpty() {
referencedSnapshots[eval.SnapshotID.String()] = struct{}{}
}
}
if err = evalIter.Err(); err != nil {
return nil
}); err != nil {
return err
}
@ -463,44 +445,39 @@ func (tu *tidyUpUsecase) TidySnapshots() error {
}
defer iter.Close()
for iter.Next() {
snap := iter.Item()
return iterateTidy(iter, dropInvalid, func(snap *happydns.ObservationSnapshot) error {
if _, ok := referencedSnapshots[snap.Id.String()]; !ok {
log.Printf("Deleting orphan snapshot: %s\n", snap.Id.String())
if err = iter.DropItem(); err != nil {
if err := iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyUsers() error {
func (tu *tidyUpUsecase) TidyUsers(dropInvalid bool) error {
iter, err := tu.store.ListAllAuthUsers()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
authUser := iter.Item()
return iterateTidy(iter, dropInvalid, func(authUser *happydns.UserAuth) error {
if authUser.EmailVerification == nil && authUser.LastLoggedIn == nil && time.Since(authUser.CreatedAt) > 7*24*time.Hour {
log.Printf("Deleting user with unverified email and no login (created %s): %s\n", authUser.CreatedAt.Format(time.RFC3339), authUser.Email)
if err = tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
if err := tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
return err
}
if err = iter.DropItem(); err != nil {
if err := iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyZones() error {
func (tu *tidyUpUsecase) TidyZones(dropInvalid bool) error {
iterdn, err := tu.store.ListAllDomains()
if err != nil {
return err
@ -508,15 +485,10 @@ func (tu *tidyUpUsecase) TidyZones() error {
defer iterdn.Close()
var referencedZones []happydns.Identifier
for iterdn.Next() {
domain := iterdn.Item()
for _, zh := range domain.ZoneHistory {
referencedZones = append(referencedZones, zh)
}
}
if err = iterdn.Err(); err != nil {
if err = iterateTidy(iterdn, dropInvalid, func(domain *happydns.Domain) error {
referencedZones = append(referencedZones, domain.ZoneHistory...)
return nil
}); err != nil {
return err
}
@ -526,9 +498,7 @@ func (tu *tidyUpUsecase) TidyZones() error {
}
defer iter.Close()
for iter.Next() {
zone := iter.Item()
return iterateTidy(iter, dropInvalid, func(zone *happydns.ZoneMessage) error {
foundZone := false
for _, zid := range referencedZones {
if zid.Equals(zone.Id) {
@ -540,11 +510,10 @@ func (tu *tidyUpUsecase) TidyZones() error {
if !foundZone {
// Drop orphan zones
log.Printf("Deleting orphan zone: %s\n", zone.Id.String())
if err = iter.DropItem(); err != nil {
if err := iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}

View file

@ -80,7 +80,7 @@ func TestTidyObservationCache_RemovesStaleEntries(t *testing.T) {
// Run tidy.
tu := usecase.NewTidyUpUsecase(store)
if err := tu.TidyObservationCache(); err != nil {
if err := tu.TidyObservationCache(true); err != nil {
t.Fatalf("TidyObservationCache() error: %v", err)
}
@ -102,7 +102,7 @@ func TestTidyObservationCache_EmptyCache(t *testing.T) {
}
tu := usecase.NewTidyUpUsecase(store)
if err := tu.TidyObservationCache(); err != nil {
if err := tu.TidyObservationCache(true); err != nil {
t.Fatalf("TidyObservationCache() on empty cache error: %v", err)
}
}

View file

@ -22,13 +22,17 @@
package happydns
type Backup struct {
Version int
Domains []*Domain
DomainsLogs map[string][]*DomainLog
Errors []string
Providers []*ProviderMessage
Sessions []*Session
Users []*User
UsersAuth UserAuths
Zones []*ZoneMessage
Version int
Domains []*Domain
DomainsLogs map[string][]*DomainLog
Errors []string
Providers []*ProviderMessage
Sessions []*Session
Users []*User
UsersAuth UserAuths
Zones []*ZoneMessage
CheckerConfigurations []*CheckerOptionsPositional
CheckPlans []*CheckPlan
CheckEvaluations []*CheckEvaluation
Executions []*Execution
}

View file

@ -24,18 +24,21 @@ package happydns
import ()
type TidyUpUseCase interface {
TidyAll() error
TidyAuthUsers() error
TidyCheckEvaluations() error
TidyCheckPlans() error
TidyCheckerConfigurations() error
TidyExecutions() error
TidyObservationCache() error
TidySnapshots() error
TidyDomains() error
TidyDomainLogs() error
TidyProviders() error
TidySessions() error
TidyUsers() error
TidyZones() error
// TidyAll runs every tidy pass. When dropInvalid is true, iterators
// that encounter undecodable records (e.g. legacy schema drift) will
// delete those records; otherwise they are only logged.
TidyAll(dropInvalid bool) error
TidyAuthUsers(dropInvalid bool) error
TidyCheckEvaluations(dropInvalid bool) error
TidyCheckPlans(dropInvalid bool) error
TidyCheckerConfigurations(dropInvalid bool) error
TidyExecutions(dropInvalid bool) error
TidyObservationCache(dropInvalid bool) error
TidySnapshots(dropInvalid bool) error
TidyDomains(dropInvalid bool) error
TidyDomainLogs(dropInvalid bool) error
TidyProviders(dropInvalid bool) error
TidySessions(dropInvalid bool) error
TidyUsers(dropInvalid bool) error
TidyZones(dropInvalid bool) error
}

View file

@ -1,48 +1,16 @@
{
"name": "happyDomain",
"name": "happydomain-monorepo",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "happyDomain",
"name": "happydomain-monorepo",
"version": "0.0.1",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
}
"workspaces": [
"web",
"web-admin"
]
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
@ -227,6 +195,14 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@happydomain/web": {
"resolved": "web",
"link": true
},
"node_modules/@happydomain/web-admin": {
"resolved": "web-admin",
"link": true
},
"node_modules/@hey-api/codegen-core": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.4.tgz",
@ -332,29 +308,43 @@
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@ -441,9 +431,9 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@ -1269,17 +1259,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
"integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/type-utils": "8.58.1",
"@typescript-eslint/utils": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/type-utils": "8.58.2",
"@typescript-eslint/utils": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@ -1292,7 +1282,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.58.1",
"@typescript-eslint/parser": "^8.58.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@ -1308,16 +1298,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3"
},
"engines": {
@ -1333,14 +1323,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz",
"integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
"integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.58.1",
"@typescript-eslint/types": "^8.58.1",
"@typescript-eslint/tsconfig-utils": "^8.58.2",
"@typescript-eslint/types": "^8.58.2",
"debug": "^4.4.3"
},
"engines": {
@ -1355,14 +1345,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
"integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
"integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1"
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1373,9 +1363,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz",
"integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
"integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
"dev": true,
"license": "MIT",
"engines": {
@ -1390,15 +1380,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz",
"integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
"integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/utils": "8.58.1",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/utils": "8.58.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@ -1415,9 +1405,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
"integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
"devOptional": true,
"license": "MIT",
"engines": {
@ -1429,16 +1419,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
"integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.58.1",
"@typescript-eslint/tsconfig-utils": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"@typescript-eslint/project-service": "8.58.2",
"@typescript-eslint/tsconfig-utils": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@ -1457,16 +1447,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
"integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
"integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1"
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1481,13 +1471,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
"integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/types": "8.58.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@ -2082,18 +2072,18 @@
}
},
"node_modules/eslint": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz",
"integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.4",
"@eslint/config-helpers": "^0.5.4",
"@eslint/core": "^1.2.0",
"@eslint/plugin-kit": "^0.7.0",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.5.5",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@ -3302,9 +3292,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@ -3461,9 +3451,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
@ -3755,16 +3745,16 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svelte": {
"version": "5.55.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz",
"integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==",
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@ -4093,16 +4083,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
"integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
"integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.58.1",
"@typescript-eslint/parser": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/utils": "8.58.1"
"@typescript-eslint/eslint-plugin": "8.58.2",
"@typescript-eslint/parser": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/utils": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4420,6 +4410,86 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
},
"web": {
"name": "@happydomain/web",
"version": "0.0.1",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
}
},
"web-admin": {
"name": "@happydomain/web-admin",
"version": "0.0.1",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
}
}
}
}

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "happydomain-monorepo",
"version": "0.0.1",
"private": true,
"workspaces": [
"web",
"web-admin"
],
"resolutions": {
"vite": "^8.0.0"
}
}

View file

@ -56,6 +56,13 @@ func GetDomainWhoisInfo(ctx context.Context, domain happydns.Origin) (*happydns.
return nil, err
}
// Some registries (e.g. Verisign for .com) return a "No match" response
// that the parser accepts without error but produces an empty Domain
// field. Treat this as a non-existent domain.
if result.Domain == nil || result.Domain.Domain == "" {
return nil, happydns.ErrDomainDoesNotExist
}
return mapWhoisResult(&result), nil
}

View file

@ -315,6 +315,7 @@ func inferOperation(name string) string {
{"Count", "count"},
{"Create", "create"},
{"Update", "update"},
{"Restore", "restore"},
{"Delete", "delete"},
{"Clear", "delete"},
{"Set", "set"},

View file

@ -1 +0,0 @@
../web/.npmrc

View file

@ -1 +0,0 @@
../web/.prettierignore

13
web-admin/.prettierignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1 +0,0 @@
../web/.prettierrc

View file

@ -1 +0,0 @@
../web/eslint.config.js

View file

@ -1 +0,0 @@
../web/node_modules/

View file

@ -1 +0,0 @@
../web/package-lock.json

View file

@ -1 +0,0 @@
../web/package.json

56
web-admin/package.json Normal file
View file

@ -0,0 +1,56 @@
{
"name": "@happydomain/web-admin",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"resolutions": {
"vite": "^8.0.0"
},
"type": "module",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
}
}

View file

@ -1,5 +1,5 @@
{
"name": "happyDomain",
"name": "@happydomain/web",
"version": "0.0.1",
"private": true,
"scripts": {

View file

@ -132,7 +132,7 @@
</div>
{:then sessions}
<ListGroup>
{#each sessions as session (session.id)}
{#each [...sessions].sort((a, b) => new Date(b.upd || b.time).getTime() - new Date(a.upd || a.time).getTime()) as session (session.id)}
<ListGroupItem class="d-flex align-items-center justify-content-between">
<div class="flex-fill" style="max-width:90%">
<div class="text-truncate">