feat(checker): add domain availability watchlist
Introduce a DomainAvailabilityWatch entity (model, storage, usecase and REST endpoints) letting a user track a domain they do not own and get notified the moment it becomes available for registration. A dedicated domain_availability checker reads WHOIS/RDAP via pkg/domaininfo and inverts the status (OK while registered, Crit once free) so the existing dispatcher fires exactly once on the transition. The scheduler enumerates watches and enqueues the check, carrying the watch id in CheckTarget.DomainId; autofill and notification payloads fall back to the watch store to resolve the name. Watches are included in per-user backup/restore. The web UI adds an availability watchlist page and navigation entry.
This commit is contained in:
parent
0d40971940
commit
5ccf81173f
30 changed files with 1423 additions and 109 deletions
157
checkers/domain_availability.go
Normal file
157
checkers/domain_availability.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/dnschecker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||
)
|
||||
|
||||
// ObservationKeyAvailability is the observation key for domain availability data.
|
||||
const ObservationKeyAvailability happydns.ObservationKey = "availability"
|
||||
|
||||
// AvailabilityData represents domain availability observation data.
|
||||
type AvailabilityData struct {
|
||||
Available bool `json:"available"`
|
||||
Registrar string `json:"registrar,omitempty"`
|
||||
ExpiryDate *time.Time `json:"expiryDate,omitempty"`
|
||||
}
|
||||
|
||||
// availabilityProvider collects whether a domain name is currently registered.
|
||||
type availabilityProvider struct{}
|
||||
|
||||
func (p *availabilityProvider) Key() happydns.ObservationKey {
|
||||
return ObservationKeyAvailability
|
||||
}
|
||||
|
||||
func (p *availabilityProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
domainName, _ := opts["domainName"].(string)
|
||||
if domainName == "" {
|
||||
return nil, fmt.Errorf("domainName is required")
|
||||
}
|
||||
|
||||
info, err := domaininfo.GetDomainInfo(ctx, happydns.Origin(domainName))
|
||||
if err != nil {
|
||||
if errors.Is(err, happydns.ErrDomainDoesNotExist) {
|
||||
return &AvailabilityData{Available: true}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
|
||||
}
|
||||
|
||||
return &AvailabilityData{
|
||||
Available: false,
|
||||
Registrar: info.Registrar,
|
||||
ExpiryDate: info.ExpirationDate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// domainAvailabilityRule emits a notify-worthy status when a watched domain
|
||||
// becomes available for registration. The status is inverted relative to the
|
||||
// usual convention (Crit when available) so the registered->available
|
||||
// transition crosses the notification threshold and the dispatcher fires once.
|
||||
type domainAvailabilityRule struct{}
|
||||
|
||||
func (r *domainAvailabilityRule) Name() string {
|
||||
return "domain_availability_check"
|
||||
}
|
||||
|
||||
func (r *domainAvailabilityRule) Description() string {
|
||||
return "Checks whether a watched domain name has become available for registration"
|
||||
}
|
||||
|
||||
func (r *domainAvailabilityRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *domainAvailabilityRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) []happydns.CheckState {
|
||||
var data AvailabilityData
|
||||
if err := obs.Get(ctx, ObservationKeyAvailability, &data); err != nil {
|
||||
return []happydns.CheckState{{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get availability data: %v", err),
|
||||
Code: "availability_error",
|
||||
}}
|
||||
}
|
||||
|
||||
domainName, _ := opts["domainName"].(string)
|
||||
|
||||
if data.Available {
|
||||
return []happydns.CheckState{{
|
||||
Status: happydns.StatusCrit,
|
||||
Message: fmt.Sprintf("Domain %s is now available for registration", domainName),
|
||||
Code: "available",
|
||||
}}
|
||||
}
|
||||
|
||||
meta := map[string]any{}
|
||||
if data.Registrar != "" {
|
||||
meta["registrar"] = data.Registrar
|
||||
}
|
||||
if data.ExpiryDate != nil {
|
||||
meta["expiry_date"] = data.ExpiryDate
|
||||
}
|
||||
|
||||
return []happydns.CheckState{{
|
||||
Status: happydns.StatusOK,
|
||||
Message: fmt.Sprintf("Domain %s is still registered", domainName),
|
||||
Code: "registered",
|
||||
Meta: meta,
|
||||
}}
|
||||
}
|
||||
|
||||
func init() {
|
||||
dnschecker.RegisterObservationProvider(&availabilityProvider{})
|
||||
|
||||
dnschecker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "domain_availability",
|
||||
Name: "Domain Availability",
|
||||
// All Availability flags are left false so IsAutoScheduled never
|
||||
// schedules this checker on managed domains. It is scheduled only via
|
||||
// the dedicated availability-watch enumeration in the scheduler.
|
||||
Availability: happydns.CheckerAvailability{},
|
||||
ObservationKeys: []happydns.ObservationKey{ObservationKeyAvailability},
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&domainAvailabilityRule{},
|
||||
},
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
180
internal/api/controller/domain_availability.go
Normal file
180
internal/api/controller/domain_availability.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type DomainAvailabilityWatchController struct {
|
||||
watchService happydns.DomainAvailabilityWatchUsecase
|
||||
}
|
||||
|
||||
func NewDomainAvailabilityWatchController(watchService happydns.DomainAvailabilityWatchUsecase) *DomainAvailabilityWatchController {
|
||||
return &DomainAvailabilityWatchController{
|
||||
watchService: watchService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListDomainAvailabilityWatches retrieves all availability watches owned by the user.
|
||||
//
|
||||
// @Summary Retrieve user's availability watches
|
||||
// @Schemes
|
||||
// @Description Retrieve all domain availability watches belonging to the user.
|
||||
// @Tags availability
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {array} happydns.DomainAvailabilityWatch
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Unable to retrieve watches"
|
||||
// @Router /availability [get]
|
||||
func (wc *DomainAvailabilityWatchController) ListDomainAvailabilityWatches(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined"})
|
||||
return
|
||||
}
|
||||
|
||||
watches, err := wc.watchService.ListUserDomainAvailabilityWatches(user)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, watches)
|
||||
}
|
||||
|
||||
// AddDomainAvailabilityWatch registers a new availability watch.
|
||||
//
|
||||
// @Summary Add a new availability watch
|
||||
// @Schemes
|
||||
// @Description Register a new domain availability watch for the user.
|
||||
// @Tags availability
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body happydns.DomainAvailabilityWatchCreationInput true "Watch to add"
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} happydns.DomainAvailabilityWatch
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Error in received data"
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Database writing error"
|
||||
// @Router /availability [post]
|
||||
func (wc *DomainAvailabilityWatchController) AddDomainAvailabilityWatch(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
if user == nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("No user specified."))
|
||||
return
|
||||
}
|
||||
|
||||
var input happydns.DomainAvailabilityWatchCreationInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Unable to decode given watch: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
watch, err := wc.watchService.CreateDomainAvailabilityWatch(c.Request.Context(), user, &input)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, watch)
|
||||
}
|
||||
|
||||
// GetDomainAvailabilityWatch retrieves a single availability watch owned by the user.
|
||||
//
|
||||
// @Summary Retrieve an availability watch
|
||||
// @Schemes
|
||||
// @Description Retrieve a single domain availability watch owned by the user.
|
||||
// @Tags availability
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param watchId path string true "Watch identifier"
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} happydns.DomainAvailabilityWatch
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Watch not found"
|
||||
// @Router /availability/{watchId} [get]
|
||||
func (wc *DomainAvailabilityWatchController) GetDomainAvailabilityWatch(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := happydns.NewIdentifierFromString(c.Param("watchId"))
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("Invalid watch identifier: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
watch, err := wc.watchService.GetUserDomainAvailabilityWatch(user, id)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, watch)
|
||||
}
|
||||
|
||||
// DeleteDomainAvailabilityWatch removes an availability watch owned by the user.
|
||||
//
|
||||
// @Summary Delete an availability watch
|
||||
// @Schemes
|
||||
// @Description Delete a domain availability watch owned by the user.
|
||||
// @Tags availability
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param watchId path string true "Watch identifier"
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 204 "Watch deleted"
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Watch not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Database writing error"
|
||||
// @Router /availability/{watchId} [delete]
|
||||
func (wc *DomainAvailabilityWatchController) DeleteDomainAvailabilityWatch(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
if user == nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("User not defined."))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := happydns.NewIdentifierFromString(c.Param("watchId"))
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("Invalid watch identifier: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := wc.watchService.DeleteDomainAvailabilityWatch(user, id); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ func ErrorResponse(c *gin.Context, defaultStatus int, err error) {
|
|||
|
||||
c.AbortWithStatusJSON(status, e.ToErrorResponse())
|
||||
return
|
||||
} else if errors.Is(err, happydns.ErrAuthUserNotFound) || errors.Is(err, happydns.ErrDomainNotFound) || errors.Is(err, happydns.ErrDomainLogNotFound) || errors.Is(err, happydns.ErrProviderNotFound) || errors.Is(err, happydns.ErrSessionNotFound) || errors.Is(err, happydns.ErrUserNotFound) || errors.Is(err, happydns.ErrUserAlreadyExist) || errors.Is(err, happydns.ErrZoneNotFound) {
|
||||
} else if errors.Is(err, happydns.ErrAuthUserNotFound) || errors.Is(err, happydns.ErrDomainNotFound) || errors.Is(err, happydns.ErrDomainAvailabilityWatchNotFound) || errors.Is(err, happydns.ErrDomainLogNotFound) || errors.Is(err, happydns.ErrProviderNotFound) || errors.Is(err, happydns.ErrSessionNotFound) || errors.Is(err, happydns.ErrUserNotFound) || errors.Is(err, happydns.ErrUserAlreadyExist) || errors.Is(err, happydns.ErrZoneNotFound) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{
|
||||
Message: err.Error(),
|
||||
})
|
||||
|
|
|
|||
38
internal/api/route/domain_availability.go
Normal file
38
internal/api/route/domain_availability.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func DeclareDomainAvailabilityWatchRoutes(router *gin.RouterGroup, watchUC happydns.DomainAvailabilityWatchUsecase) {
|
||||
wc := controller.NewDomainAvailabilityWatchController(watchUC)
|
||||
|
||||
router.GET("/availability", wc.ListDomainAvailabilityWatches)
|
||||
router.POST("/availability", wc.AddDomainAvailabilityWatch)
|
||||
router.GET("/availability/:watchId", wc.GetDomainAvailabilityWatch)
|
||||
router.DELETE("/availability/:watchId", wc.DeleteDomainAvailabilityWatch)
|
||||
}
|
||||
|
|
@ -40,28 +40,29 @@ import (
|
|||
// Dependencies holds all use cases required to register the public API routes.
|
||||
// It is a plain struct - no methods, no interface - constructed once in app.go.
|
||||
type Dependencies struct {
|
||||
Backup happydns.BackupUsecase
|
||||
Authentication happydns.AuthenticationUsecase
|
||||
AuthUser happydns.AuthUserUsecase
|
||||
CaptchaVerifier happydns.CaptchaVerifier
|
||||
Domain happydns.DomainUsecase
|
||||
DomainInfo happydns.DomainInfoUsecase
|
||||
DomainLog happydns.DomainLogUsecase
|
||||
EmailAutoconfig happydns.EmailAutoconfigUsecase
|
||||
FailureTracker happydns.FailureTracker
|
||||
Provider happydns.ProviderUsecase
|
||||
ProviderSettings happydns.ProviderSettingsUsecase
|
||||
ProviderSpecs happydns.ProviderSpecsUsecase
|
||||
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
|
||||
Resolver happydns.ResolverUsecase
|
||||
Service happydns.ServiceUsecase
|
||||
ServiceSpecs happydns.ServiceSpecsUsecase
|
||||
Session happydns.SessionUsecase
|
||||
User happydns.UserUsecase
|
||||
Zone happydns.ZoneUsecase
|
||||
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
|
||||
ZoneImporter happydns.ZoneImporterUsecase
|
||||
ZoneService happydns.ZoneServiceUsecase
|
||||
Backup happydns.BackupUsecase
|
||||
Authentication happydns.AuthenticationUsecase
|
||||
AuthUser happydns.AuthUserUsecase
|
||||
CaptchaVerifier happydns.CaptchaVerifier
|
||||
Domain happydns.DomainUsecase
|
||||
DomainAvailabilityWatch happydns.DomainAvailabilityWatchUsecase
|
||||
DomainInfo happydns.DomainInfoUsecase
|
||||
DomainLog happydns.DomainLogUsecase
|
||||
EmailAutoconfig happydns.EmailAutoconfigUsecase
|
||||
FailureTracker happydns.FailureTracker
|
||||
Provider happydns.ProviderUsecase
|
||||
ProviderSettings happydns.ProviderSettingsUsecase
|
||||
ProviderSpecs happydns.ProviderSpecsUsecase
|
||||
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
|
||||
Resolver happydns.ResolverUsecase
|
||||
Service happydns.ServiceUsecase
|
||||
ServiceSpecs happydns.ServiceSpecsUsecase
|
||||
Session happydns.SessionUsecase
|
||||
User happydns.UserUsecase
|
||||
Zone happydns.ZoneUsecase
|
||||
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
|
||||
ZoneImporter happydns.ZoneImporterUsecase
|
||||
ZoneService happydns.ZoneServiceUsecase
|
||||
|
||||
CheckerEngine happydns.CheckerEngine
|
||||
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
|
|
@ -192,6 +193,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
dep.DomainInfo,
|
||||
nc,
|
||||
)
|
||||
if dep.DomainAvailabilityWatch != nil {
|
||||
DeclareDomainAvailabilityWatchRoutes(apiAuthRoutes, dep.DomainAvailabilityWatch)
|
||||
}
|
||||
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
||||
DeclareRecordRoutes(apiAuthRoutes)
|
||||
|
|
|
|||
|
|
@ -47,27 +47,28 @@ import (
|
|||
)
|
||||
|
||||
type Usecases struct {
|
||||
backup happydns.BackupUsecase
|
||||
authentication happydns.AuthenticationUsecase
|
||||
authUser happydns.AuthUserUsecase
|
||||
authUserAdmin happydns.AdminAuthUserUsecase
|
||||
domain happydns.DomainUsecase
|
||||
domainAdmin happydns.AdminDomainUsecase
|
||||
domainInfo happydns.DomainInfoUsecase
|
||||
domainLog happydns.DomainLogUsecase
|
||||
emailAutoconfig happydns.EmailAutoconfigUsecase
|
||||
provider happydns.ProviderUsecase
|
||||
providerAdmin happydns.ProviderUsecase
|
||||
providerSpecs happydns.ProviderSpecsUsecase
|
||||
providerSettings happydns.ProviderSettingsUsecase
|
||||
resolver happydns.ResolverUsecase
|
||||
session happydns.SessionUsecase
|
||||
service happydns.ServiceUsecase
|
||||
serviceSpecs happydns.ServiceSpecsUsecase
|
||||
user happydns.UserUsecase
|
||||
userAdmin happydns.AdminUserUsecase
|
||||
zone happydns.ZoneUsecase
|
||||
zoneService happydns.ZoneServiceUsecase
|
||||
backup happydns.BackupUsecase
|
||||
authentication happydns.AuthenticationUsecase
|
||||
authUser happydns.AuthUserUsecase
|
||||
authUserAdmin happydns.AdminAuthUserUsecase
|
||||
domain happydns.DomainUsecase
|
||||
domainAdmin happydns.AdminDomainUsecase
|
||||
domainAvailabilityWatch happydns.DomainAvailabilityWatchUsecase
|
||||
domainInfo happydns.DomainInfoUsecase
|
||||
domainLog happydns.DomainLogUsecase
|
||||
emailAutoconfig happydns.EmailAutoconfigUsecase
|
||||
provider happydns.ProviderUsecase
|
||||
providerAdmin happydns.ProviderUsecase
|
||||
providerSpecs happydns.ProviderSpecsUsecase
|
||||
providerSettings happydns.ProviderSettingsUsecase
|
||||
resolver happydns.ResolverUsecase
|
||||
session happydns.SessionUsecase
|
||||
service happydns.ServiceUsecase
|
||||
serviceSpecs happydns.ServiceSpecsUsecase
|
||||
user happydns.UserUsecase
|
||||
userAdmin happydns.AdminUserUsecase
|
||||
zone happydns.ZoneUsecase
|
||||
zoneService happydns.ZoneServiceUsecase
|
||||
|
||||
orchestrator *orchestrator.Orchestrator
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ func (s *instrumentedStorage) ClearDiscoveryObservationRefs() (err error) {
|
|||
return s.inner.ClearDiscoveryObservationRefs()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearDomainAvailabilityWatches() (err error) {
|
||||
defer observe("delete", "domain_availability_watch")(&err)
|
||||
return s.inner.ClearDomainAvailabilityWatches()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearDomains() (err error) {
|
||||
defer observe("delete", "domain")(&err)
|
||||
return s.inner.ClearDomains()
|
||||
|
|
@ -152,6 +157,11 @@ func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error)
|
|||
return s.inner.CreateDomain(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) (err error) {
|
||||
defer observe("create", "domain_availability_watch")(&err)
|
||||
return s.inner.CreateDomainAvailabilityWatch(watch)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("create", "domain_log")(&err)
|
||||
return s.inner.CreateDomainLog(domain, log)
|
||||
|
|
@ -232,6 +242,11 @@ func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err er
|
|||
return s.inner.DeleteDomain(domainid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteDomainAvailabilityWatch(id happydns.Identifier) (err error) {
|
||||
defer observe("delete", "domain_availability_watch")(&err)
|
||||
return s.inner.DeleteDomainAvailabilityWatch(id)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("delete", "domain_log")(&err)
|
||||
return s.inner.DeleteDomainLog(domain, log)
|
||||
|
|
@ -337,6 +352,11 @@ func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happ
|
|||
return s.inner.GetDomain(domainid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetDomainAvailabilityWatch(id happydns.Identifier) (ret *happydns.DomainAvailabilityWatch, err error) {
|
||||
defer observe("get", "domain_availability_watch")(&err)
|
||||
return s.inner.GetDomainAvailabilityWatch(id)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
|
||||
defer observe("get", "domain")(&err)
|
||||
return s.inner.GetDomainByDN(user, fqdn)
|
||||
|
|
@ -447,6 +467,11 @@ func (s *instrumentedStorage) ListAllDiscoveryObservationRefs() (ret happydns.It
|
|||
return s.inner.ListAllDiscoveryObservationRefs()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllDomainAvailabilityWatches() (ret happydns.Iterator[happydns.DomainAvailabilityWatch], err error) {
|
||||
defer observe("list", "domain_availability_watch")(&err)
|
||||
return s.inner.ListAllDomainAvailabilityWatches()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
|
||||
defer observe("list", "domain_log")(&err)
|
||||
return s.inner.ListAllDomainLogs()
|
||||
|
|
@ -537,6 +562,11 @@ func (s *instrumentedStorage) ListDiscoveryObservationRefs(producerID string, ta
|
|||
return s.inner.ListDiscoveryObservationRefs(producerID, target, ref)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListDomainAvailabilityWatches(user *happydns.User) (ret []*happydns.DomainAvailabilityWatch, err error) {
|
||||
defer observe("list", "domain_availability_watch")(&err)
|
||||
return s.inner.ListDomainAvailabilityWatches(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
|
||||
defer observe("list", "domain_log")(&err)
|
||||
return s.inner.ListDomainLogs(domain)
|
||||
|
|
@ -701,6 +731,11 @@ func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error)
|
|||
return s.inner.UpdateDomain(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) (err error) {
|
||||
defer observe("update", "domain_availability_watch")(&err)
|
||||
return s.inner.UpdateDomainAvailabilityWatch(watch)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("update", "domain_log")(&err)
|
||||
return s.inner.UpdateDomainLog(domain, log)
|
||||
|
|
|
|||
|
|
@ -57,28 +57,29 @@ func (app *App) setupRouter() {
|
|||
app.cfg,
|
||||
baserouter,
|
||||
api.Dependencies{
|
||||
Backup: app.usecases.backup,
|
||||
Authentication: app.usecases.authentication,
|
||||
AuthUser: app.usecases.authUser,
|
||||
CaptchaVerifier: app.captchaVerifier,
|
||||
Domain: app.usecases.domain,
|
||||
DomainInfo: app.usecases.domainInfo,
|
||||
DomainLog: app.usecases.domainLog,
|
||||
EmailAutoconfig: app.usecases.emailAutoconfig,
|
||||
FailureTracker: app.failureTracker,
|
||||
Provider: app.usecases.provider,
|
||||
ProviderSettings: app.usecases.providerSettings,
|
||||
ProviderSpecs: app.usecases.providerSpecs,
|
||||
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
|
||||
Resolver: app.usecases.resolver,
|
||||
Service: app.usecases.service,
|
||||
ServiceSpecs: app.usecases.serviceSpecs,
|
||||
Session: app.usecases.session,
|
||||
User: app.usecases.user,
|
||||
Zone: app.usecases.zone,
|
||||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
Backup: app.usecases.backup,
|
||||
Authentication: app.usecases.authentication,
|
||||
AuthUser: app.usecases.authUser,
|
||||
CaptchaVerifier: app.captchaVerifier,
|
||||
Domain: app.usecases.domain,
|
||||
DomainAvailabilityWatch: app.usecases.domainAvailabilityWatch,
|
||||
DomainInfo: app.usecases.domainInfo,
|
||||
DomainLog: app.usecases.domainLog,
|
||||
EmailAutoconfig: app.usecases.emailAutoconfig,
|
||||
FailureTracker: app.failureTracker,
|
||||
Provider: app.usecases.provider,
|
||||
ProviderSettings: app.usecases.providerSettings,
|
||||
ProviderSpecs: app.usecases.providerSpecs,
|
||||
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
|
||||
Resolver: app.usecases.resolver,
|
||||
Service: app.usecases.service,
|
||||
ServiceSpecs: app.usecases.serviceSpecs,
|
||||
Session: app.usecases.session,
|
||||
User: app.usecases.user,
|
||||
Zone: app.usecases.zone,
|
||||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
|
||||
CheckerEngine: app.usecases.checkerEngine,
|
||||
CheckerOptionsUC: app.usecases.checkerOptionsUC,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import (
|
|||
backupUC "git.happydns.org/happyDomain/internal/usecase/backup"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
domainAvailabilityUC "git.happydns.org/happyDomain/internal/usecase/domain_availability"
|
||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
emailAutoconfigUC "git.happydns.org/happyDomain/internal/usecase/emailautoconfig"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
|
|
@ -89,6 +90,8 @@ func (app *App) initUsecases() {
|
|||
)
|
||||
app.usecases.domain = domainService
|
||||
app.usecases.domainAdmin = domainService
|
||||
availabilityWatchService := domainAvailabilityUC.NewService(app.store)
|
||||
app.usecases.domainAvailabilityWatch = availabilityWatchService
|
||||
app.usecases.zoneService = zoneServiceUC.NewZoneServiceUsecases(
|
||||
domainService,
|
||||
zoneService.CreateZoneUC,
|
||||
|
|
@ -125,6 +128,7 @@ func (app *App) initUsecases() {
|
|||
// Checker system.
|
||||
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store).
|
||||
WithDiscoveryEntryStore(app.store).
|
||||
WithWatchStore(app.store).
|
||||
WithAdminOptions(app.cfg.CheckerAdminOptions)
|
||||
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
|
||||
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store, app.usecases.checkerOptionsUC)
|
||||
|
|
@ -144,7 +148,7 @@ func (app *App) initUsecases() {
|
|||
app.usecases.checkerScheduler = checkerUC.NewScheduler(
|
||||
app.usecases.checkerEngine,
|
||||
app.cfg.CheckerMaxConcurrency,
|
||||
app.store, app.store, app.store, app.store,
|
||||
app.store, app.store, app.store, app.store, app.store,
|
||||
app.usecases.checkerUserGater.AllowWithInterval,
|
||||
app.usecases.checkerUserGater.IncrementUsage,
|
||||
)
|
||||
|
|
@ -169,6 +173,7 @@ func (app *App) initUsecases() {
|
|||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
availabilityWatchService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
|
||||
// Notification system: dispatcher fans out checker results to user
|
||||
// channels (email/webhook/UnifiedPush) based on per-target preferences.
|
||||
|
|
@ -193,7 +198,7 @@ func (app *App) initUsecases() {
|
|||
tester,
|
||||
ack,
|
||||
stateLocker,
|
||||
)
|
||||
).WithWatchStore(app.store)
|
||||
if cb, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok {
|
||||
cb.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/usecase/authuser"
|
||||
"git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain_availability"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/insight"
|
||||
"git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
|
|
@ -52,6 +53,7 @@ type Storage interface {
|
|||
checker.ObservationSnapshotStorage
|
||||
checker.SchedulerStateStorage
|
||||
domain.DomainStorage
|
||||
domain_availability.DomainAvailabilityWatchStorage
|
||||
domainlog.DomainLogStorage
|
||||
insight.InsightStorage
|
||||
notification.NotificationChannelStorage
|
||||
|
|
|
|||
149
internal/storage/kvtpl/domain_availability.go
Normal file
149
internal/storage/kvtpl/domain_availability.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Secondary index for the availability-watch entity.
|
||||
//
|
||||
// availwatch.owner|{ownerId}|{watchId} -> "" reverse lookup by owner
|
||||
const (
|
||||
availWatchPrimaryPrefix = "availwatch-"
|
||||
availWatchOwnerIndexPrefix = "availwatch.owner|"
|
||||
)
|
||||
|
||||
func availWatchOwnerIndexKey(ownerId, watchId happydns.Identifier) string {
|
||||
return fmt.Sprintf("%s%s|%s", availWatchOwnerIndexPrefix, ownerId.String(), watchId.String())
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllDomainAvailabilityWatches() (happydns.Iterator[happydns.DomainAvailabilityWatch], error) {
|
||||
iter := s.db.Search(availWatchPrimaryPrefix)
|
||||
return NewKVIterator[happydns.DomainAvailabilityWatch](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListDomainAvailabilityWatches(u *happydns.User) (watches []*happydns.DomainAvailabilityWatch, err error) {
|
||||
prefix := fmt.Sprintf("%s%s|", availWatchOwnerIndexPrefix, u.Id.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
id, kerr := lastKeySegment(iter.Key())
|
||||
if kerr != nil {
|
||||
continue
|
||||
}
|
||||
w, gerr := s.GetDomainAvailabilityWatch(id)
|
||||
if gerr != nil {
|
||||
log.Printf("ListDomainAvailabilityWatches: stale owner index %q -> missing watch: %v", iter.Key(), gerr)
|
||||
continue
|
||||
}
|
||||
watches = append(watches, w)
|
||||
}
|
||||
|
||||
err = iter.Err()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *KVStorage) getDomainAvailabilityWatch(key string) (*happydns.DomainAvailabilityWatch, error) {
|
||||
watch := &happydns.DomainAvailabilityWatch{}
|
||||
err := s.db.Get(key, watch)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrDomainAvailabilityWatchNotFound
|
||||
}
|
||||
return watch, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error) {
|
||||
return s.getDomainAvailabilityWatch(fmt.Sprintf("%s%s", availWatchPrimaryPrefix, id.String()))
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreateDomainAvailabilityWatch(w *happydns.DomainAvailabilityWatch) error {
|
||||
key, id, err := s.db.FindIdentifierKey(availWatchPrimaryPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Id = id
|
||||
if err := s.db.Put(key, w); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Put(availWatchOwnerIndexKey(w.Owner, w.Id), "")
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateDomainAvailabilityWatch(w *happydns.DomainAvailabilityWatch) error {
|
||||
primaryKey := fmt.Sprintf("%s%s", availWatchPrimaryPrefix, w.Id.String())
|
||||
|
||||
// Load the previous record to detect owner changes. UpdateDomainAvailabilityWatch
|
||||
// is used by the backup restore path where the primary may not exist yet, so
|
||||
// a missing old record is not an error.
|
||||
old, err := s.GetDomainAvailabilityWatch(w.Id)
|
||||
if err != nil && !errors.Is(err, happydns.ErrDomainAvailabilityWatchNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Put(primaryKey, w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if old != nil && !old.Owner.Equals(w.Owner) {
|
||||
if delErr := s.db.Delete(availWatchOwnerIndexKey(old.Owner, old.Id)); delErr != nil {
|
||||
log.Printf("UpdateDomainAvailabilityWatch: failed to delete stale owner index for owner %s: %v", old.Owner.String(), delErr)
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Put(availWatchOwnerIndexKey(w.Owner, w.Id), "")
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteDomainAvailabilityWatch(id happydns.Identifier) error {
|
||||
if w, err := s.GetDomainAvailabilityWatch(id); err == nil {
|
||||
if delErr := s.db.Delete(availWatchOwnerIndexKey(w.Owner, w.Id)); delErr != nil {
|
||||
log.Printf("DeleteDomainAvailabilityWatch: failed to delete owner index for owner %s: %v", w.Owner.String(), delErr)
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Delete(fmt.Sprintf("%s%s", availWatchPrimaryPrefix, id.String()))
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearDomainAvailabilityWatches() error {
|
||||
if err := s.clearByPrefix(availWatchOwnerIndexPrefix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
iter, err := s.ListAllDomainAvailabilityWatches()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
if err = s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
}
|
||||
|
|
@ -85,6 +85,14 @@ func (u *Usecase) backupOneUser(user *happydns.User, ret *happydns.Backup) {
|
|||
} else {
|
||||
ret.Sessions = append(ret.Sessions, ss...)
|
||||
}
|
||||
|
||||
// Domain availability watches
|
||||
ws, err := u.store.ListDomainAvailabilityWatches(user)
|
||||
if err != nil {
|
||||
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve DomainAvailabilityWatches: %s", err.Error()))
|
||||
} else {
|
||||
ret.DomainAvailabilityWatches = append(ret.DomainAvailabilityWatches, ws...)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Usecase) Backup() happydns.Backup {
|
||||
|
|
@ -319,6 +327,11 @@ func (u *Usecase) Restore(backup *happydns.Backup) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Domain availability watches
|
||||
for _, watch := range backup.DomainAvailabilityWatches {
|
||||
errs = errors.Join(errs, u.store.UpdateDomainAvailabilityWatch(watch))
|
||||
}
|
||||
|
||||
// Zones
|
||||
for _, zmsg := range backup.Zones {
|
||||
zone, err := zoneUC.ParseZone(zmsg)
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ type CheckerOptionsUsecase struct {
|
|||
store CheckerOptionsStorage
|
||||
autoFillStore CheckAutoFillStorage
|
||||
discoveryStore DiscoveryEntryStorage
|
||||
watchStore WatchGetter
|
||||
adminOptions map[string]happydns.CheckerOptions
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +168,14 @@ func (u *CheckerOptionsUsecase) WithDiscoveryEntryStore(store DiscoveryEntryStor
|
|||
return u
|
||||
}
|
||||
|
||||
// WithWatchStore enables resolving a CheckTarget whose DomainId refers to a
|
||||
// domain availability watch (rather than a real Domain) during auto-fill, so
|
||||
// the availability checker receives the watched name. Passing nil is a no-op.
|
||||
func (u *CheckerOptionsUsecase) WithWatchStore(store WatchGetter) *CheckerOptionsUsecase {
|
||||
u.watchStore = store
|
||||
return u
|
||||
}
|
||||
|
||||
// WithAdminOptions installs per-checker admin-scope option overrides sourced
|
||||
// from CLI flags / env vars. They are applied with the highest priority in
|
||||
// GetCheckerOptions (so the admin panel reflects effective values) and in
|
||||
|
|
@ -655,6 +664,15 @@ func (u *CheckerOptionsUsecase) buildAutoFillContext(
|
|||
|
||||
domain, err := u.autoFillStore.GetDomain(*domainId)
|
||||
if err != nil {
|
||||
// The DomainId may refer to an availability watch rather than a real
|
||||
// Domain. Fall back to the watch store so the availability checker
|
||||
// receives the watched name.
|
||||
if u.watchStore != nil {
|
||||
if watch, werr := u.watchStore.GetDomainAvailabilityWatch(*domainId); werr == nil {
|
||||
ctx[happydns.AutoFillDomainName] = watch.DomainName
|
||||
return ctx, nil
|
||||
}
|
||||
}
|
||||
return ctx, fmt.Errorf("loading domain for auto-fill: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ const (
|
|||
minSpacing = 2 * time.Second
|
||||
maxCatchUpWindow = 10 * time.Minute
|
||||
defaultInterval = 24 * time.Hour
|
||||
|
||||
// availabilityCheckerID is the id of the dedicated checker run against
|
||||
// domain availability watches. It has all CheckerAvailability flags false
|
||||
// so it is never auto-scheduled on managed domains; the scheduler enqueues
|
||||
// it only from the watch enumeration.
|
||||
availabilityCheckerID = "domain_availability"
|
||||
)
|
||||
|
||||
// SchedulerJob represents a single scheduled checker execution.
|
||||
|
|
@ -100,6 +106,7 @@ type Scheduler struct {
|
|||
engine happydns.CheckerEngine
|
||||
planStore CheckPlanStorage
|
||||
domainStore DomainLister
|
||||
watchStore WatchLister
|
||||
zoneStore ZoneGetter
|
||||
stateStore SchedulerStateStorage
|
||||
cancel context.CancelFunc
|
||||
|
|
@ -136,6 +143,7 @@ func NewScheduler(
|
|||
maxConcurrency int,
|
||||
planStore CheckPlanStorage,
|
||||
domainStore DomainLister,
|
||||
watchStore WatchLister,
|
||||
zoneStore ZoneGetter,
|
||||
stateStore SchedulerStateStorage,
|
||||
gate func(target happydns.CheckTarget, interval time.Duration) bool,
|
||||
|
|
@ -148,6 +156,7 @@ func NewScheduler(
|
|||
engine: engine,
|
||||
planStore: planStore,
|
||||
domainStore: domainStore,
|
||||
watchStore: watchStore,
|
||||
zoneStore: zoneStore,
|
||||
stateStore: stateStore,
|
||||
jobKeys: make(map[string]bool),
|
||||
|
|
@ -505,6 +514,15 @@ func (s *Scheduler) buildQueue() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Availability watches: schedule the dedicated availability checker for
|
||||
// each watched name. These are not real domains, so domain-scoped checkers
|
||||
// do not apply; only domain_availability runs, bypassing IsAutoScheduled.
|
||||
if availDef, ok := checkers[availabilityCheckerID]; ok {
|
||||
for _, watch := range s.loadAllWatches() {
|
||||
s.enqueueWatchJob(availDef, watch, lastRun)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDomainChange incrementally adds scheduler jobs for a domain
|
||||
|
|
@ -636,6 +654,35 @@ func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
|
|||
}
|
||||
}
|
||||
|
||||
// NotifyWatchChange incrementally adds the availability checker job for a
|
||||
// newly created watch without rebuilding the entire queue.
|
||||
func (s *Scheduler) NotifyWatchChange(watch *happydns.DomainAvailabilityWatch) {
|
||||
availDef, ok := checkerPkg.GetCheckers()[availabilityCheckerID]
|
||||
if !ok {
|
||||
log.Printf("Scheduler: NotifyWatchChange: checker %q not registered", availabilityCheckerID)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
added := s.enqueueWatchJob(availDef, watch, time.Time{})
|
||||
s.mu.Unlock()
|
||||
|
||||
if added {
|
||||
log.Printf("Scheduler: NotifyWatchChange(%s): added availability job", watch.DomainName)
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWatchRemoved removes the availability checker job for the given watch.
|
||||
// The watch id is carried in CheckTarget.DomainId, so removal matches on the
|
||||
// same field as NotifyDomainRemoved.
|
||||
func (s *Scheduler) NotifyWatchRemoved(watchID happydns.Identifier) {
|
||||
s.NotifyDomainRemoved(watchID)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -747,6 +794,72 @@ func (s *Scheduler) loadAllDomains() []*happydns.Domain {
|
|||
return domains
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadAllWatches() []*happydns.DomainAvailabilityWatch {
|
||||
if s.watchStore == nil {
|
||||
return nil
|
||||
}
|
||||
iter, err := s.watchStore.ListAllDomainAvailabilityWatches()
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to list availability watches: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var watches []*happydns.DomainAvailabilityWatch
|
||||
for iter.Next() {
|
||||
watches = append(watches, iter.Item())
|
||||
}
|
||||
return watches
|
||||
}
|
||||
|
||||
// enqueueWatchJob enqueues the availability checker for a single watch. It
|
||||
// mirrors enqueueJob but carries the watch id in CheckTarget.DomainId and
|
||||
// honours the watch's optional custom interval (clamped to the checker's
|
||||
// bounds). Must be called with s.mu held. Returns true if a job was added.
|
||||
func (s *Scheduler) enqueueWatchJob(def *happydns.CheckerDefinition, watch *happydns.DomainAvailabilityWatch, lastActive time.Time) bool {
|
||||
target := happydns.CheckTarget{UserId: watch.Owner.String(), DomainId: watch.Id.String()}
|
||||
targetStr := target.String()
|
||||
key := availabilityCheckerID + "|" + targetStr
|
||||
if s.jobKeys[key] {
|
||||
return false
|
||||
}
|
||||
|
||||
interval := s.effectiveInterval(def, nil)
|
||||
if watch.Interval != nil && *watch.Interval > 0 {
|
||||
interval = *watch.Interval
|
||||
if def.Interval != nil {
|
||||
if interval < def.Interval.Min {
|
||||
interval = def.Interval.Min
|
||||
}
|
||||
if interval > def.Interval.Max {
|
||||
interval = def.Interval.Max
|
||||
}
|
||||
}
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = defaultInterval
|
||||
}
|
||||
|
||||
var nextRun time.Time
|
||||
if lastActive.IsZero() {
|
||||
now := time.Now()
|
||||
nextRun = now.Add(computeJitter(availabilityCheckerID, targetStr, now, interval))
|
||||
} else {
|
||||
offset := computeOffset(availabilityCheckerID, targetStr, interval)
|
||||
nextRun = computeNextRun(interval, offset, lastActive)
|
||||
}
|
||||
|
||||
job := &SchedulerJob{
|
||||
CheckerID: availabilityCheckerID,
|
||||
Target: target,
|
||||
Interval: interval,
|
||||
NextRun: nextRun,
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.ServiceMessage {
|
||||
if s.zoneStore == nil || len(domain.ZoneHistory) == 0 {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ func newTestScheduler(engine happydns.CheckerEngine, domains []*happydns.Domain)
|
|||
dl := &mockDomainLister{domains: domains}
|
||||
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
|
||||
ss := &mockStateStore{}
|
||||
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil, nil)
|
||||
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, nil, nil)
|
||||
return sched, ps, ss
|
||||
}
|
||||
|
||||
|
|
@ -343,7 +343,7 @@ func TestScheduler_Gate(t *testing.T) {
|
|||
dl := &mockDomainLister{domains: []*happydns.Domain{domain}}
|
||||
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
|
||||
ss := &mockStateStore{}
|
||||
sched := NewScheduler(engine, 2, ps, dl, zg, ss, func(target happydns.CheckTarget, interval time.Duration) bool {
|
||||
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, func(target happydns.CheckTarget, interval time.Duration) bool {
|
||||
gated.Add(1)
|
||||
return false // block all jobs
|
||||
}, nil)
|
||||
|
|
@ -386,7 +386,7 @@ func TestScheduler_OnExecute_CalledOnSuccess(t *testing.T) {
|
|||
|
||||
var onExecCalls atomic.Int32
|
||||
var lastTarget atomic.Value // happydns.CheckTarget
|
||||
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil, func(target happydns.CheckTarget) {
|
||||
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, nil, func(target happydns.CheckTarget) {
|
||||
onExecCalls.Add(1)
|
||||
lastTarget.Store(target)
|
||||
})
|
||||
|
|
@ -432,7 +432,7 @@ func TestScheduler_OnExecute_NotCalledWhenCreateFails(t *testing.T) {
|
|||
ss := &mockStateStore{}
|
||||
|
||||
var onExecCalls atomic.Int32
|
||||
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil, func(target happydns.CheckTarget) {
|
||||
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss, nil, func(target happydns.CheckTarget) {
|
||||
onExecCalls.Add(1)
|
||||
})
|
||||
|
||||
|
|
@ -473,7 +473,7 @@ func TestScheduler_OnExecute_NotCalledWhenGateDenies(t *testing.T) {
|
|||
ss := &mockStateStore{}
|
||||
|
||||
var onExecCalls atomic.Int32
|
||||
sched := NewScheduler(engine, 2, ps, dl, zg, ss,
|
||||
sched := NewScheduler(engine, 2, ps, dl, nil, zg, ss,
|
||||
func(target happydns.CheckTarget, interval time.Duration) bool { return false },
|
||||
func(target happydns.CheckTarget) { onExecCalls.Add(1) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,18 @@ type ZoneGetter interface {
|
|||
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
|
||||
}
|
||||
|
||||
// WatchLister is the minimal interface needed by the scheduler to enumerate
|
||||
// domain availability watches.
|
||||
type WatchLister interface {
|
||||
ListAllDomainAvailabilityWatches() (happydns.Iterator[happydns.DomainAvailabilityWatch], error)
|
||||
}
|
||||
|
||||
// WatchGetter resolves a single availability watch by id. Used as a fallback
|
||||
// when a CheckTarget's DomainId refers to a watch rather than a real domain.
|
||||
type WatchGetter interface {
|
||||
GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error)
|
||||
}
|
||||
|
||||
// CheckAutoFillStorage provides access to domain, zone and user data
|
||||
// needed to resolve auto-fill field values at execution time.
|
||||
type CheckAutoFillStorage interface {
|
||||
|
|
|
|||
111
internal/usecase/domain_availability/domain_availability.go
Normal file
111
internal/usecase/domain_availability/domain_availability.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package domain_availability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store DomainAvailabilityWatchStorage
|
||||
schedulerNotifier happydns.SchedulerWatchNotifier
|
||||
}
|
||||
|
||||
func NewService(store DomainAvailabilityWatchStorage) *Service {
|
||||
return &Service{store: store}
|
||||
}
|
||||
|
||||
// SetSchedulerNotifier sets the optional scheduler notifier for incremental
|
||||
// queue updates on watch creation/deletion.
|
||||
func (s *Service) SetSchedulerNotifier(notifier happydns.SchedulerWatchNotifier) {
|
||||
s.schedulerNotifier = notifier
|
||||
}
|
||||
|
||||
func (s *Service) CreateDomainAvailabilityWatch(ctx context.Context, user *happydns.User, input *happydns.DomainAvailabilityWatchCreationInput) (*happydns.DomainAvailabilityWatch, error) {
|
||||
watch, err := happydns.NewDomainAvailabilityWatch(user, input.DomainName)
|
||||
if err != nil {
|
||||
return nil, happydns.ValidationError{Msg: err.Error()}
|
||||
}
|
||||
watch.Interval = input.Interval
|
||||
|
||||
if err := s.store.CreateDomainAvailabilityWatch(watch); err != nil {
|
||||
return nil, happydns.InternalError{
|
||||
Err: fmt.Errorf("unable to CreateDomainAvailabilityWatch: %w", err),
|
||||
UserMessage: "Sorry, we are unable to create your availability watch now.",
|
||||
}
|
||||
}
|
||||
|
||||
if s.schedulerNotifier != nil {
|
||||
s.schedulerNotifier.NotifyWatchChange(watch)
|
||||
}
|
||||
|
||||
return watch, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUserDomainAvailabilityWatch(user *happydns.User, id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error) {
|
||||
watch, err := s.store.GetDomainAvailabilityWatch(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !user.Id.Equals(watch.Owner) {
|
||||
return nil, happydns.ErrDomainAvailabilityWatchNotFound
|
||||
}
|
||||
|
||||
return watch, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListUserDomainAvailabilityWatches(user *happydns.User) ([]*happydns.DomainAvailabilityWatch, error) {
|
||||
watches, err := s.store.ListDomainAvailabilityWatches(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to ListUserDomainAvailabilityWatches: %w", err)
|
||||
}
|
||||
|
||||
if len(watches) == 0 {
|
||||
return []*happydns.DomainAvailabilityWatch{}, nil
|
||||
}
|
||||
|
||||
return watches, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteDomainAvailabilityWatch(user *happydns.User, id happydns.Identifier) error {
|
||||
// Ensure the watch exists and is owned by the caller before deleting.
|
||||
if _, err := s.GetUserDomainAvailabilityWatch(user, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.store.DeleteDomainAvailabilityWatch(id); err != nil {
|
||||
return happydns.InternalError{
|
||||
Err: fmt.Errorf("unable to DeleteDomainAvailabilityWatch: %w", err),
|
||||
UserMessage: "Sorry, we are unable to delete your availability watch now.",
|
||||
}
|
||||
}
|
||||
|
||||
if s.schedulerNotifier != nil {
|
||||
s.schedulerNotifier.NotifyWatchRemoved(id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
50
internal/usecase/domain_availability/storage.go
Normal file
50
internal/usecase/domain_availability/storage.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package domain_availability
|
||||
|
||||
import (
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type DomainAvailabilityWatchStorage interface {
|
||||
// ListAllDomainAvailabilityWatches retrieves every registered watch.
|
||||
ListAllDomainAvailabilityWatches() (happydns.Iterator[happydns.DomainAvailabilityWatch], error)
|
||||
|
||||
// ListDomainAvailabilityWatches retrieves all watches owned by the User.
|
||||
ListDomainAvailabilityWatches(user *happydns.User) ([]*happydns.DomainAvailabilityWatch, error)
|
||||
|
||||
// GetDomainAvailabilityWatch retrieves the watch with the given id.
|
||||
GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error)
|
||||
|
||||
// CreateDomainAvailabilityWatch persists a new watch.
|
||||
CreateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) error
|
||||
|
||||
// UpdateDomainAvailabilityWatch persists an existing watch, preserving its
|
||||
// id. Used by the backup restore path.
|
||||
UpdateDomainAvailabilityWatch(watch *happydns.DomainAvailabilityWatch) error
|
||||
|
||||
// DeleteDomainAvailabilityWatch removes the watch with the given id.
|
||||
DeleteDomainAvailabilityWatch(id happydns.Identifier) error
|
||||
|
||||
// ClearDomainAvailabilityWatches deletes all watches.
|
||||
ClearDomainAvailabilityWatches() error
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ type Dispatcher struct {
|
|||
userStore UserGetter
|
||||
domainStore DomainGetter
|
||||
zoneStore ZoneGetter
|
||||
watchStore WatchGetter
|
||||
|
||||
resolver *Resolver
|
||||
pool *Pool
|
||||
|
|
@ -74,6 +75,14 @@ func NewDispatcher(
|
|||
}
|
||||
}
|
||||
|
||||
// WithWatchStore enables resolving a CheckTarget whose DomainId refers to a
|
||||
// domain availability watch (rather than a real Domain), so notifications carry
|
||||
// the watched name. Passing nil is a no-op.
|
||||
func (d *Dispatcher) WithWatchStore(store WatchGetter) *Dispatcher {
|
||||
d.watchStore = store
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Start() { d.pool.Start() }
|
||||
func (d *Dispatcher) Stop() { d.pool.Stop() }
|
||||
|
||||
|
|
@ -193,6 +202,15 @@ func (d *Dispatcher) buildPayload(user *happydns.User, exec *happydns.Execution,
|
|||
}
|
||||
}
|
||||
}
|
||||
if domainName == "" && d.watchStore != nil {
|
||||
// The DomainId may refer to an availability watch rather than a real
|
||||
// Domain. Fall back to the watch store for the watched name.
|
||||
if did := happydns.TargetIdentifier(exec.Target.DomainId); did != nil {
|
||||
if watch, err := d.watchStore.GetDomainAvailabilityWatch(*did); err == nil {
|
||||
domainName = watch.DomainName
|
||||
}
|
||||
}
|
||||
}
|
||||
if domainName == "" {
|
||||
domainName = "(unknown domain)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,3 +67,10 @@ type DomainGetter interface {
|
|||
type ZoneGetter interface {
|
||||
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
|
||||
}
|
||||
|
||||
// WatchGetter resolves a domain availability watch by id. Used as a fallback
|
||||
// when a notification's CheckTarget.DomainId refers to a watch rather than a
|
||||
// real Domain, so the payload carries the watched name.
|
||||
type WatchGetter interface {
|
||||
GetDomainAvailabilityWatch(id happydns.Identifier) (*happydns.DomainAvailabilityWatch, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,22 +22,23 @@
|
|||
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
|
||||
CheckerConfigurations []*CheckerOptionsPositional
|
||||
CheckPlans []*CheckPlan
|
||||
CheckEvaluations []*CheckEvaluation
|
||||
Executions []*Execution
|
||||
DiscoveryEntries []*StoredDiscoveryEntry
|
||||
DiscoveryObservationRefs []*DiscoveryObservationRef
|
||||
ObservationSnapshots []*ObservationSnapshot
|
||||
Version int
|
||||
Domains []*Domain
|
||||
DomainAvailabilityWatches []*DomainAvailabilityWatch
|
||||
DomainsLogs map[string][]*DomainLog
|
||||
Errors []string
|
||||
Providers []*ProviderMessage
|
||||
Sessions []*Session
|
||||
Users []*User
|
||||
UsersAuth UserAuths
|
||||
Zones []*ZoneMessage
|
||||
CheckerConfigurations []*CheckerOptionsPositional
|
||||
CheckPlans []*CheckPlan
|
||||
CheckEvaluations []*CheckEvaluation
|
||||
Executions []*Execution
|
||||
DiscoveryEntries []*StoredDiscoveryEntry
|
||||
DiscoveryObservationRefs []*DiscoveryObservationRef
|
||||
ObservationSnapshots []*ObservationSnapshot
|
||||
}
|
||||
|
||||
// BackupUsecase orchestrates the export and re-import of every persistent
|
||||
|
|
|
|||
98
model/domain_availability.go
Normal file
98
model/domain_availability.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// DomainAvailabilityWatch tracks a domain name the User does NOT own, so they
|
||||
// can be notified the moment it becomes available for registration. Unlike a
|
||||
// Domain, a watch is not tied to any Provider and never manages a zone.
|
||||
type DomainAvailabilityWatch struct {
|
||||
// Id is the watch's identifier in the database.
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
|
||||
// Owner is the identifier of the User watching the domain.
|
||||
Owner Identifier `json:"id_owner" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
|
||||
// DomainName is the FQDN being watched for availability.
|
||||
DomainName string `json:"domain" binding:"required"`
|
||||
|
||||
// Interval optionally overrides how often the availability check runs.
|
||||
// When nil, the checker's default interval is used.
|
||||
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
|
||||
|
||||
// CreatedAt records when the watch was registered.
|
||||
CreatedAt time.Time `json:"created_at" readonly:"true"`
|
||||
}
|
||||
|
||||
// DomainAvailabilityWatchCreationInput is used for swagger documentation as
|
||||
// availability watch add.
|
||||
type DomainAvailabilityWatchCreationInput struct {
|
||||
// DomainName is the FQDN to watch for availability.
|
||||
DomainName string `json:"domain" binding:"required"`
|
||||
|
||||
// Interval optionally overrides the default check interval.
|
||||
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
|
||||
}
|
||||
|
||||
// NewDomainAvailabilityWatch validates the name and builds a watch owned by the
|
||||
// given user.
|
||||
func NewDomainAvailabilityWatch(user *User, name string) (*DomainAvailabilityWatch, error) {
|
||||
name = dns.Fqdn(strings.TrimSpace(name))
|
||||
|
||||
if name == "." {
|
||||
return nil, errors.New("empty domain name")
|
||||
}
|
||||
|
||||
if _, ok := dns.IsDomainName(name); !ok {
|
||||
return nil, errors.New("invalid domain name")
|
||||
}
|
||||
|
||||
return &DomainAvailabilityWatch{
|
||||
Owner: user.Id,
|
||||
DomainName: name,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DomainAvailabilityWatchUsecase exposes owner-scoped operations on the
|
||||
// availability watchlist.
|
||||
type DomainAvailabilityWatchUsecase interface {
|
||||
CreateDomainAvailabilityWatch(context.Context, *User, *DomainAvailabilityWatchCreationInput) (*DomainAvailabilityWatch, error)
|
||||
DeleteDomainAvailabilityWatch(*User, Identifier) error
|
||||
GetUserDomainAvailabilityWatch(*User, Identifier) (*DomainAvailabilityWatch, error)
|
||||
ListUserDomainAvailabilityWatches(*User) ([]*DomainAvailabilityWatch, error)
|
||||
}
|
||||
|
||||
// SchedulerWatchNotifier is an optional callback to notify the scheduler about
|
||||
// availability-watch changes so it can incrementally update its job queue.
|
||||
type SchedulerWatchNotifier interface {
|
||||
NotifyWatchChange(watch *DomainAvailabilityWatch)
|
||||
NotifyWatchRemoved(watchID Identifier)
|
||||
}
|
||||
|
|
@ -27,24 +27,25 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrAuthUserNotFound = errors.New("auth user not found")
|
||||
ErrCheckPlanNotFound = errors.New("check plan not found")
|
||||
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
|
||||
ErrCheckerNotFound = errors.New("checker not found")
|
||||
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrExecutionNotFound = errors.New("execution not found")
|
||||
ErrNotificationChannelNotFound = errors.New("notification channel not found")
|
||||
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
|
||||
ErrNotificationStateNotFound = errors.New("notification state not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrSnapshotNotFound = errors.New("snapshot not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAuthUserNotFound = errors.New("auth user not found")
|
||||
ErrCheckPlanNotFound = errors.New("check plan not found")
|
||||
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
|
||||
ErrCheckerNotFound = errors.New("checker not found")
|
||||
ErrDomainAvailabilityWatchNotFound = errors.New("domain availability watch not found")
|
||||
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrExecutionNotFound = errors.New("execution not found")
|
||||
ErrNotificationChannelNotFound = errors.New("notification channel not found")
|
||||
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
|
||||
ErrNotificationStateNotFound = errors.New("notification state not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrSnapshotNotFound = errors.New("snapshot not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ var entityMap = map[string]string{
|
|||
"ObservationSnapshotStorage": "observation_snapshot",
|
||||
"SchedulerStateStorage": "scheduler_state",
|
||||
"DomainStorage": "domain",
|
||||
"DomainAvailabilityWatchStorage": "domain_availability_watch",
|
||||
"DomainLogStorage": "domain_log",
|
||||
"InsightStorage": "insight",
|
||||
"NotificationChannelStorage": "notification_channel",
|
||||
|
|
|
|||
68
web/src/lib/api/availability.ts
Normal file
68
web/src/lib/api/availability.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
getAvailability,
|
||||
getAvailabilityByWatchId,
|
||||
postAvailability,
|
||||
deleteAvailabilityByWatchId,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import type {
|
||||
HappydnsDomainAvailabilityWatch,
|
||||
HappydnsDomainAvailabilityWatchCreationInput,
|
||||
} from "$lib/api-base/types.gen";
|
||||
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
|
||||
|
||||
export async function listAvailabilityWatches(): Promise<
|
||||
Array<HappydnsDomainAvailabilityWatch>
|
||||
> {
|
||||
return unwrapSdkResponse(
|
||||
await getAvailability(),
|
||||
) as Array<HappydnsDomainAvailabilityWatch>;
|
||||
}
|
||||
|
||||
export async function getAvailabilityWatch(
|
||||
id: string,
|
||||
): Promise<HappydnsDomainAvailabilityWatch> {
|
||||
return unwrapSdkResponse(
|
||||
await getAvailabilityByWatchId({
|
||||
path: { watchId: id },
|
||||
}),
|
||||
) as HappydnsDomainAvailabilityWatch;
|
||||
}
|
||||
|
||||
export async function addAvailabilityWatch(
|
||||
body: HappydnsDomainAvailabilityWatchCreationInput,
|
||||
): Promise<HappydnsDomainAvailabilityWatch> {
|
||||
return unwrapSdkResponse(
|
||||
await postAvailability({
|
||||
body,
|
||||
}),
|
||||
) as HappydnsDomainAvailabilityWatch;
|
||||
}
|
||||
|
||||
export async function deleteAvailabilityWatch(id: string): Promise<boolean> {
|
||||
return unwrapEmptyResponse(
|
||||
await deleteAvailabilityByWatchId({
|
||||
path: { watchId: id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -139,6 +139,13 @@
|
|||
<Icon name="info-circle" class="me-2" />
|
||||
{$t("menu.whois")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
active={page.route && page.route.id == "/availability"}
|
||||
href="/availability"
|
||||
>
|
||||
<Icon name="bell" class="me-2" />
|
||||
{$t("menu.availability")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
active={page.route &&
|
||||
(page.route.id == "/checkers" ||
|
||||
|
|
|
|||
|
|
@ -281,7 +281,8 @@
|
|||
"provider-features": "Supported providers",
|
||||
"signup": "Sign up",
|
||||
"signin": "Sign in",
|
||||
"quick-menu": "Quick Access"
|
||||
"quick-menu": "Quick Access",
|
||||
"availability": "Availability watchlist"
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
|
|
@ -1434,5 +1435,19 @@
|
|||
"detail": "The mechanism {{mechanism}} could not be fully evaluated."
|
||||
}
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"title": "Availability watchlist",
|
||||
"description": "Get notified as soon as a domain you do not own becomes available for registration.",
|
||||
"kind": "watch",
|
||||
"add": "Watch a domain",
|
||||
"domain-placeholder": "example.com",
|
||||
"empty": "You are not watching any domain yet.",
|
||||
"status-available": "Available",
|
||||
"status-registered": "Still registered",
|
||||
"added-success": "Now watching {{domain}} for availability.",
|
||||
"deleted-success": "Stopped watching {{domain}}.",
|
||||
"delete-confirm": "Stop watching {{domain}}?",
|
||||
"watched-since": "Watched since"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -279,7 +279,8 @@
|
|||
"signup": "S'inscrire",
|
||||
"signin": "S'identifier",
|
||||
"quick-menu": "Accès rapide",
|
||||
"my-providers": "Les hébergeurs de mes domaines"
|
||||
"my-providers": "Les hébergeurs de mes domaines",
|
||||
"availability": "Surveillance de disponibilité"
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
|
|
@ -1074,5 +1075,19 @@
|
|||
"detail": "Le mécanisme {{mechanism}} n'a pas pu être totalement évalué."
|
||||
}
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"title": "Liste de surveillance de disponibilité",
|
||||
"description": "Soyez notifié dès qu'un domaine que vous ne possédez pas devient disponible à l'enregistrement.",
|
||||
"kind": "surveillance",
|
||||
"add": "Surveiller un domaine",
|
||||
"domain-placeholder": "exemple.fr",
|
||||
"empty": "Vous ne surveillez aucun domaine pour le moment.",
|
||||
"status-available": "Disponible",
|
||||
"status-registered": "Toujours enregistré",
|
||||
"added-success": "Surveillance de la disponibilité de {{domain}} activée.",
|
||||
"deleted-success": "Surveillance de {{domain}} arrêtée.",
|
||||
"delete-confirm": "Arrêter de surveiller {{domain}} ?",
|
||||
"watched-since": "Surveillé depuis"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
web/src/lib/stores/availability.ts
Normal file
34
web/src/lib/stores/availability.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { listAvailabilityWatches } from "$lib/api/availability";
|
||||
import type { HappydnsDomainAvailabilityWatch } from "$lib/api-base/types.gen";
|
||||
|
||||
export const availabilityWatches: Writable<
|
||||
Array<HappydnsDomainAvailabilityWatch> | undefined
|
||||
> = writable(undefined);
|
||||
|
||||
export async function refreshAvailabilityWatches() {
|
||||
const data = await listAvailabilityWatches();
|
||||
availabilityWatches.set(data);
|
||||
return data;
|
||||
}
|
||||
160
web/src/routes/availability/+page.svelte
Normal file
160
web/src/routes/availability/+page.svelte
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { addAvailabilityWatch, deleteAvailabilityWatch } from "$lib/api/availability";
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import { availabilityWatches, refreshAvailabilityWatches } from "$lib/stores/availability";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
let newDomain = $state("");
|
||||
let adding = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
refreshAvailabilityWatches();
|
||||
});
|
||||
|
||||
async function onAdd() {
|
||||
const domain = newDomain.trim();
|
||||
if (!domain) return;
|
||||
|
||||
adding = true;
|
||||
try {
|
||||
await addAvailabilityWatch({ domain });
|
||||
toasts.addToast({
|
||||
title: $t("availability.add"),
|
||||
message: $t("availability.added-success", { domain }),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
newDomain = "";
|
||||
await refreshAvailabilityWatches();
|
||||
} catch (err) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("errors.error"),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(id: string, domain: string) {
|
||||
if (!confirm($t("availability.delete-confirm", { domain }))) return;
|
||||
|
||||
try {
|
||||
await deleteAvailabilityWatch(id);
|
||||
toasts.addToast({
|
||||
title: $t("availability.title"),
|
||||
message: $t("availability.deleted-success", { domain }),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
await refreshAvailabilityWatches();
|
||||
} catch (err) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("errors.error"),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("availability.title")} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<PageTitle title={$t("availability.title")} subtitle={$t("availability.description")} />
|
||||
|
||||
<form
|
||||
class="mb-4 mt-3"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onAdd();
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={$t("availability.domain-placeholder")}
|
||||
bind:value={newDomain}
|
||||
disabled={adding}
|
||||
/>
|
||||
<Button type="submit" color="primary" disabled={adding || !newDomain.trim()}>
|
||||
{#if adding}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="plus" />
|
||||
{/if}
|
||||
{$t("availability.add")}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
|
||||
{#if $availabilityWatches === undefined}
|
||||
<div class="text-center my-5">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if $availabilityWatches.length === 0}
|
||||
<p class="text-muted text-center my-5">{$t("availability.empty")}</p>
|
||||
{:else}
|
||||
<ListGroup>
|
||||
{#each $availabilityWatches as watch (watch.id)}
|
||||
<ListGroupItem class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="font-monospace fw-bold">{watch.domain}</span>
|
||||
{#if watch.created_at}
|
||||
<small class="text-muted ms-2">
|
||||
{$t("availability.watched-since")}
|
||||
{new Date(watch.created_at).toLocaleDateString()}
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="danger"
|
||||
outline
|
||||
size="sm"
|
||||
onclick={() => onDelete(watch.id, watch.domain)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
{/if}
|
||||
</Container>
|
||||
Loading…
Add table
Add a link
Reference in a new issue