Compare commits

...

10 commits

Author SHA1 Message Date
8a2a28e4be providers: Mark secret fields with secret tag; add eye toggle for secret inputs
All checks were successful
continuous-integration/drone/push Build is passing
Also fix a typo in oracle.go label ("Private hey" → "Private key").
2026-03-16 19:44:14 +07:00
e341ea6beb chore(deps): lock file maintenance 2026-03-16 19:44:14 +07:00
69c9ba1d8d Expand authuser test coverage: hash functions, validation, recovery, and bcrypt limit 2026-03-16 19:44:14 +07:00
50ff2a1c7a Replace nil mailer checks with LogMailer fallback
Add a LogMailer that prints emails to stdout when no mail transport is
configured, eliminating the reflect-based nil interface checks that were
scattered across the authuser package. The App now always injects a
non-nil Mailer, so the usecase layer no longer needs to guard against it.
2026-03-16 19:44:14 +07:00
fece9cc4a5 Improve password validation performance and email format checking 2026-03-16 19:44:14 +07:00
9203e71494 web: Rename /join route to /register for clarity 2026-03-16 19:44:14 +07:00
36a7d8e9d3 Fix email validation HMAC weakness and prevent user enumeration on registration 2026-03-16 19:44:14 +07:00
ae675d6451 Refactor provider usecase: fix ownership bug, use decorator pattern, enforce service layer
- Fix DeleteProvider skipping ownership check by adding getUserProvider call
- Replace RestrictedService struct embedding with decorator pattern to prevent
  silent method promotion bypassing restriction checks
- Make providerSettingsUsecase delegate to ProviderUsecase instead of accessing
  storage directly, ensuring validation and ownership are enforced
- Accept ProviderValidator as constructor parameter, removing SetValidator mutator
- Add instantiate() helper for consistent provider instantiation error handling
- Wrap ListUserProviders storage errors in InternalError for consistency
- Add Test_DeleteProvider_WrongUser test and reduce test boilerplate
2026-03-16 19:44:14 +07:00
c850cfb0db Refactor orchestrator: add context.Context, fix error handling, use interfaces
- Handle AppendDomainLog errors with log.Printf instead of silently discarding
- Add NoopDomainLogAppender for null object pattern
2026-03-16 19:44:14 +07:00
07b5553369 Add public DNS record generator pages at /generator
Expose service editors publicly (no auth required) at /generator for
SEO discoverability. Each page shows an interactive editor alongside
a live DNS zone record preview powered by a new POST
/service_specs/:ssid/records backend endpoint.
2026-03-16 19:44:13 +07:00
80 changed files with 1940 additions and 653 deletions

View file

@ -210,7 +210,8 @@ func (ac *AuthUserController) DeleteAuthUser(c *gin.Context) {
func (ac *AuthUserController) EmailValidationLink(c *gin.Context) {
user := c.MustGet("authuser").(*happydns.UserAuth)
happydns.ApiResponse(c, ac.auService.GenerateValidationLink(user), nil)
link, err := ac.auService.GenerateValidationLink(user)
happydns.ApiResponse(c, link, err)
}
// RecoverUserAcct generates an account recovery link for a user.

View file

@ -234,7 +234,7 @@ func (dc *DomainController) RetrieveZone(c *gin.Context) {
}
domain := c.MustGet("domain").(*happydns.Domain)
zone, err := dc.remoteZoneImporter.Import(user, domain)
zone, err := dc.remoteZoneImporter.Import(c.Request.Context(), user, domain)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -22,22 +22,28 @@
package controller
import (
"fmt"
"log"
"net/http"
"reflect"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
"git.happydns.org/happyDomain/model"
)
type ServiceSpecsController struct {
sSpecsServices happydns.ServiceSpecsUsecase
sSpecsServices happydns.ServiceSpecsUsecase
listRecordsService *serviceUC.ListRecordsUsecase
}
func NewServiceSpecsController(sSpecsServices happydns.ServiceSpecsUsecase) *ServiceSpecsController {
return &ServiceSpecsController{
sSpecsServices: sSpecsServices,
sSpecsServices: sSpecsServices,
listRecordsService: serviceUC.NewListRecordsUsecase(),
}
}
@ -79,7 +85,7 @@ func (ssc *ServiceSpecsController) GetServiceSpecIcon(c *gin.Context) {
c.Data(http.StatusOK, "image/png", cnt)
}
// getServiceSpec returns a description of the expected fields.
// GetServiceSpec returns a description of the expected fields.
//
// @Summary Get the service expected fields.
// @Schemes
@ -127,3 +133,54 @@ func (ssc *ServiceSpecsController) InitializeServiceSpec(c *gin.Context) {
c.JSON(http.StatusOK, initialized)
}
// GenerateRecords returns the DNS records that the service would generate.
//
// @Summary Generate DNS records for a service.
// @Schemes
// @Description Return the DNS records that the given service configuration would generate.
// @Tags service_specs
// @Accept json
// @Produce json
// @Param serviceType path string true "The service's type"
// @Param domain query string true "The domain to use to generate the records"
// @Param ttl query int false "The TTL used by the generated records"
// @Success 200 {array} happydns.Record
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Service type does not exist"
// @Failure 500 {object} happydns.ErrorResponse "Internal error"
// @Router /service_specs/{serviceType}/records [post]
func (ssc *ServiceSpecsController) GenerateRecords(c *gin.Context) {
svctype := c.MustGet("servicetype").(reflect.Type)
domain := c.Query("domain")
ttl, _ := strconv.Atoi(c.Query("ttl"))
if ttl == 0 {
ttl = 3600
}
svc, err := ssc.sSpecsServices.InitializeService(svctype)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
err = c.ShouldBindJSON(&svc)
if err != nil {
log.Printf("%s sends invalid domain JSON: %s", c.ClientIP(), err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received data: %s", err.Error())})
return
}
records, err := ssc.listRecordsService.List(&happydns.Service{
ServiceMeta: happydns.ServiceMeta{
Domain: domain,
},
Service: svc.(happydns.ServiceBody),
}, domain, uint32(ttl))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, records)
}

View file

@ -52,7 +52,7 @@ func NewRegistrationController(auService happydns.AuthUserUsecase, captchaVerifi
// @Accept json
// @Produce json
// @Param body body happydns.UserRegistration true "Account information"
// @Success 200 {object} happydns.User "The created user"
// @Success 204
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /users [post]
@ -85,7 +85,10 @@ func (rc *RegistrationController) RegisterNewUser(c *gin.Context) {
return
}
log.Printf("%s: registers new user: %s", c.ClientIP(), user.Email)
if user != nil {
log.Printf("%s: registers new user: %s", c.ClientIP(), user.Email)
}
c.JSON(http.StatusOK, user)
// Always return the same response to prevent user enumeration.
c.Status(http.StatusNoContent)
}

View file

@ -107,7 +107,7 @@ func (zc *ZoneController) DiffZonesHandler(c *gin.Context) {
var corrections []*happydns.Correction
if c.Param("oldzoneid") == "@" {
var err error
corrections, nbDiffs, err = zc.zoneCorrectionService.List(user, domain, newzone)
corrections, nbDiffs, err = zc.zoneCorrectionService.List(c.Request.Context(), user, domain, newzone)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
@ -214,7 +214,7 @@ func (zc *ZoneController) ApplyZoneCorrections(c *gin.Context) {
return
}
newZone, err := zc.zoneCorrectionService.Apply(user, domain, zone, &form)
newZone, err := zc.zoneCorrectionService.Apply(c.Request.Context(), user, domain, zone, &form)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -41,4 +41,5 @@ func DeclareServiceSpecsRoutes(router *gin.RouterGroup, serviceSpecsUC happydns.
apiServiceSpecsRoutes.GET("", ssc.GetServiceSpec)
apiServiceSpecsRoutes.POST("/init", ssc.InitializeServiceSpec)
apiServiceSpecsRoutes.POST("/records", ssc.GenerateRecords)
}

View file

@ -54,7 +54,7 @@ func NewAdmin(app *App) *Admin {
router.Use(gin.Logger(), gin.Recovery())
// Prepare usecases (admin uses unrestricted provider access)
app.usecases.providerAdmin = providerUC.NewService(app.store)
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
admin.DeclareRoutes(
app.cfg,

View file

@ -76,7 +76,7 @@ type App struct {
cfg *happydns.Options
failureTracker *captcha.FailureTracker
insights *insightsCollector
mailer *mailer.Mailer
mailer happydns.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
@ -128,19 +128,22 @@ func (app *App) initCaptcha() {
func (app *App) initMailer() {
if app.cfg.MailSMTPHost != "" {
app.mailer = &mailer.Mailer{
m := &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: mailer.NewSMTPMailer(app.cfg.MailSMTPHost, app.cfg.MailSMTPPort, app.cfg.MailSMTPUsername, app.cfg.MailSMTPPassword),
}
if app.cfg.MailSMTPTLSSNoVerify {
app.mailer.SendMethod.(*mailer.SMTPMailer).WithTLSNoVerify()
m.SendMethod.(*mailer.SMTPMailer).WithTLSNoVerify()
}
app.mailer = m
} else if !app.cfg.NoMail {
app.mailer = &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: &mailer.SystemSendmail{},
}
} else {
app.mailer = &mailer.LogMailer{}
}
}
@ -193,14 +196,14 @@ func (app *App) initUsecases() {
)
domainLogService := domainlogUC.NewService(app.store)
providerService := providerUC.NewRestrictedService(app.cfg, app.store)
providerAdminService := providerUC.NewService(app.store)
providerAdminService := providerUC.NewService(app.store, nil)
serviceService := serviceUC.NewServiceUsecases()
zoneService := zoneUC.NewZoneUsecases(app.store, serviceService)
app.usecases.providerSpecs = usecase.NewProviderSpecsUsecase()
app.usecases.provider = providerService
app.usecases.providerAdmin = providerAdminService
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider, app.store)
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider)
app.usecases.service = serviceService
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
app.usecases.zone = zoneService

36
internal/mailer/log.go Normal file
View file

@ -0,0 +1,36 @@
// 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 mailer
import (
"log"
"net/mail"
)
// LogMailer is a dummy mailer that prints emails to stdout.
// It is used when no real mail transport is configured.
type LogMailer struct{}
func (l *LogMailer) SendMail(to *mail.Address, subject, content string) error {
log.Printf("--- Mail to %s ---\nSubject: %s\n\n%s\n--- End of mail ---", to.String(), subject, content)
return nil
}

View file

@ -23,10 +23,11 @@ package authuser
import (
"fmt"
"reflect"
"regexp"
"strings"
"log"
"net/mail"
"unicode"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/model"
)
@ -93,13 +94,27 @@ func (s *Service) checkPasswordConstraints(password, confirmation string) error
return happydns.ValidationError{Msg: "password must be at most 72 characters long"}
}
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
var hasLower, hasUpper, hasDigit, hasSymbol bool
for _, r := range password {
switch {
case unicode.IsLower(r):
hasLower = true
case unicode.IsUpper(r):
hasUpper = true
case unicode.IsDigit(r):
hasDigit = true
default:
hasSymbol = true
}
}
if !hasLower {
return happydns.ValidationError{Msg: "Password must contain lower case letters."}
} else if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
} else if !hasUpper {
return happydns.ValidationError{Msg: "Password must contain upper case letters."}
} else if !regexp.MustCompile(`[0-9]`).MatchString(password) {
} else if !hasDigit {
return happydns.ValidationError{Msg: "Password must contain numbers."}
} else if len(password) < 11 && !regexp.MustCompile(`[^a-zA-Z0-9]`).MatchString(password) {
} else if len(password) < 11 && !hasSymbol {
return happydns.ValidationError{Msg: "Password must be longer or contain symbols."}
}
@ -131,9 +146,11 @@ func (s *Service) ChangePassword(user *happydns.UserAuth, newPassword string) er
}
// CreateAuthUser validates the registration request, creates the user, and optionally sends a validation email.
// To prevent user enumeration, this method returns nil user with nil error when an account
// already exists with the given email address, after sending a notification to the existing user.
func (s *Service) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAuth, error) {
// Validate email format
if len(uu.Email) <= 3 || !strings.Contains(uu.Email, "@") {
if _, err := mail.ParseAddress(uu.Email); err != nil {
return nil, happydns.ValidationError{Msg: "the given email is invalid"}
}
@ -152,7 +169,9 @@ func (s *Service) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAu
}
}
if exists {
return nil, happydns.ValidationError{Msg: "an account already exists with the given address. Try logging in."}
// Send a notification to the existing user (best effort) to avoid user enumeration.
s.sendDuplicateRegistrationNotice(uu.Email)
return nil, nil
}
// Create the user object
@ -173,19 +192,40 @@ func (s *Service) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAu
}
}
// Optionally send the validation email if mailer is configured
if s.mailer != nil && !reflect.ValueOf(s.mailer).IsNil() {
if err = s.emailValidation.SendLink(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to send validation email: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
// Send the validation email
if err = s.emailValidation.SendLink(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to send validation email: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
return user, nil
}
// sendDuplicateRegistrationNotice sends an email to an existing user when someone
// attempts to register with their email address.
func (s *Service) sendDuplicateRegistrationNotice(email string) {
toName := helpers.GenUsername(email)
err := s.mailer.SendMail(
&mail.Address{Name: toName, Address: email},
"Registration attempt on happyDomain",
fmt.Sprintf(`Hi %s,
Someone (possibly you) attempted to create a new account on happyDomain
using your email address.
If this was you, you already have an account. You can log in or use the
password recovery feature if you have forgotten your password.
If this was not you, you can safely ignore this email.
`, toName),
)
if err != nil {
log.Printf("unable to send duplicate registration notice to %s: %v", email, err)
}
}
// DeleteAuthUser deletes an authenticated user from the system, ensuring their sessions are also removed.
func (s *Service) DeleteAuthUser(user *happydns.UserAuth, password string) error {
// Verify the current password
@ -235,7 +275,7 @@ func (s *Service) SendRecoveryLink(user *happydns.UserAuth) error {
}
// GenerateValidationLink generates an email validation link for the given user.
func (s *Service) GenerateValidationLink(user *happydns.UserAuth) string {
func (s *Service) GenerateValidationLink(user *happydns.UserAuth) (string, error) {
return s.emailValidation.GenerateLink(user)
}

View file

@ -22,7 +22,10 @@
package authuser_test
import (
"errors"
"fmt"
"net/mail"
"strings"
"testing"
"time"
@ -33,6 +36,13 @@ import (
"git.happydns.org/happyDomain/model"
)
// NoopMailer is a mock mailer that discards all emails.
type NoopMailer struct{}
func (n *NoopMailer) SendMail(to *mail.Address, subject, content string) error {
return nil
}
// MockCloseUserSessionsUsecase is a mock implementation of SessionCloserUsecase.
type MockCloseUserSessionsUsecase struct {
CloseAllFunc func(user happydns.UserInfo) error
@ -56,11 +66,21 @@ func setupTestService() (*authuser.Service, storage.Storage) {
DisableRegistration: false,
}
mockCloseSessions := &MockCloseUserSessionsUsecase{}
// Pass nil mailer to avoid sending emails in tests
service := authuser.NewAuthUserUsecases(cfg, nil, store, mockCloseSessions)
service := authuser.NewAuthUserUsecases(cfg, &NoopMailer{}, store, mockCloseSessions)
return service, store
}
func requireValidationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected an error, got nil")
}
var ve happydns.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
}
// ========== CanRegister Tests ==========
func TestCanRegister_Success(t *testing.T) {
@ -81,10 +101,10 @@ func TestCanRegister_Closed(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
store, _ := kv.NewKVDatabase(mem)
cfg := &happydns.Options{
DisableRegistration: true, // Registration closed
DisableRegistration: true,
}
mockCloseSessions := &MockCloseUserSessionsUsecase{}
service := authuser.NewAuthUserUsecases(cfg, nil, store, mockCloseSessions)
service := authuser.NewAuthUserUsecases(cfg, &NoopMailer{}, store, mockCloseSessions)
reg := happydns.UserRegistration{
Email: "test@example.com",
@ -92,8 +112,8 @@ func TestCanRegister_Closed(t *testing.T) {
}
err := service.CanRegister(reg)
if err == nil || err.Error() != "Registration are closed on this instance." {
t.Errorf("expected registration closed error, got: %v", err)
if err == nil {
t.Error("expected registration closed error, got nil")
}
}
@ -117,7 +137,7 @@ func TestCreateAuthUser_Success(t *testing.T) {
t.Errorf("expected email %s, got %s", reg.Email, user.Email)
}
if user.Password == nil {
t.Errorf("expected defined password, got %s", user.Password)
t.Error("expected defined password")
}
if !user.AllowCommercials {
t.Error("expected user to have AllowCommercials = true")
@ -127,53 +147,71 @@ func TestCreateAuthUser_Success(t *testing.T) {
func TestCreateAuthUser_InvalidEmail(t *testing.T) {
service, _ := setupTestService()
reg := happydns.UserRegistration{
Email: "bademail",
Password: "StrongPassword123!",
}
_, err := service.CreateAuthUser(reg)
if err == nil || err.Error() != "the given email is invalid" {
t.Errorf("expected validation error for email, got: %v", err)
cases := []string{"", "ab", "bademail", "a@"}
for _, email := range cases {
t.Run(email, func(t *testing.T) {
reg := happydns.UserRegistration{
Email: email,
Password: "StrongPassword123!",
}
_, err := service.CreateAuthUser(reg)
requireValidationError(t, err)
})
}
}
func TestCreateAuthUser_WeakPassword(t *testing.T) {
service, _ := setupTestService()
cases := []struct {
name string
password string
}{
{"too short", "123"},
{"short with symbols", "Secur3$"},
{"no uppercase", "secure123"},
{"short without symbols", "Secure123"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reg := happydns.UserRegistration{
Email: "test@example.com",
Password: tc.password,
}
_, err := service.CreateAuthUser(reg)
requireValidationError(t, err)
})
}
}
func TestCreateAuthUser_PasswordMaxLength(t *testing.T) {
service, _ := setupTestService()
// Exactly 72 characters should be accepted (bcrypt limit)
pw72 := "Abcdefg1!" + strings.Repeat("x", 63) // 9 + 63 = 72
reg := happydns.UserRegistration{
Email: "test@example.com",
Password: "123",
Email: "max72@example.com",
Password: pw72,
}
_, err := service.CreateAuthUser(reg)
if err == nil || err.Error() != "password must be at least 8 characters long" {
t.Errorf("expected password constraint error, got: %v", err)
if err != nil {
t.Fatalf("expected 72-char password to be accepted, got %v", err)
}
reg.Password = "Secur3$"
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "password must be at least 8 characters long" {
t.Errorf("expected password constraint error, got: %v", err)
// 73 characters should be rejected
pw73 := pw72 + "x"
reg = happydns.UserRegistration{
Email: "max73@example.com",
Password: pw73,
}
reg.Password = "secure123"
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "Password must contain upper case letters." {
t.Errorf("expected password constraint error, got: %v", err)
}
reg.Password = "Secure123"
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "Password must be longer or contain symbols." {
t.Errorf("expected password constraint error, got: %v", err)
}
requireValidationError(t, err)
}
func TestCreateAuthUser_EmailAlreadyUsed(t *testing.T) {
service, _ := setupTestService()
// Create a user first
reg := happydns.UserRegistration{
Email: "used@example.com",
Password: "StrongPassword123!",
@ -183,10 +221,14 @@ func TestCreateAuthUser_EmailAlreadyUsed(t *testing.T) {
t.Fatalf("setup user creation failed: %v", err)
}
// Try creating again with the same email
_, err = service.CreateAuthUser(reg)
if err == nil || err.Error() != "an account already exists with the given address. Try logging in." {
t.Errorf("expected duplicate email error, got: %v", err)
// Try creating again with the same email.
// The implementation silently succeeds (returns nil, nil) to prevent user enumeration.
user, err := service.CreateAuthUser(reg)
if err != nil {
t.Errorf("expected no error for duplicate email (anti-enumeration), got: %v", err)
}
if user != nil {
t.Errorf("expected nil user for duplicate email, got non-nil")
}
}
@ -212,7 +254,7 @@ func TestGetAuthUser(t *testing.T) {
t.Fatalf("Expected non-nil user ID, got %s", user.Id)
}
t.Run("GetAuthUser returns the correct user", func(t *testing.T) {
t.Run("returns the correct user", func(t *testing.T) {
got, err := service.GetAuthUser(user.Id)
if err != nil {
t.Errorf("Expected no error, got %v", err)
@ -222,7 +264,7 @@ func TestGetAuthUser(t *testing.T) {
}
})
t.Run("GetAuthUserByEmail returns the correct user", func(t *testing.T) {
t.Run("by email returns the correct user", func(t *testing.T) {
got, err := service.GetAuthUserByEmail("test@example.com")
if err != nil {
t.Errorf("Expected no error, got %v", err)
@ -232,14 +274,14 @@ func TestGetAuthUser(t *testing.T) {
}
})
t.Run("GetAuthUser returns error for unknown ID", func(t *testing.T) {
t.Run("returns error for unknown ID", func(t *testing.T) {
_, err := service.GetAuthUser([]byte("unknown-id"))
if err == nil {
t.Error("Expected error for unknown ID, got nil")
}
})
t.Run("GetAuthUserByEmail returns error for unknown email", func(t *testing.T) {
t.Run("returns error for unknown email", func(t *testing.T) {
_, err := service.GetAuthUserByEmail("unknown@example.com")
if err == nil {
t.Error("Expected error for unknown email, got nil")
@ -277,6 +319,24 @@ func TestChangePassword(t *testing.T) {
}
}
func TestChangePassword_WeakNewPassword(t *testing.T) {
service, store := setupTestService()
user := &happydns.UserAuth{
Email: "test@example.com",
}
user.DefinePassword("OldPassword123!")
store.CreateAuthUser(user)
err := service.ChangePassword(user, "short")
requireValidationError(t, err)
// Verify old password still works (change was not applied)
if !user.CheckPassword("OldPassword123!") {
t.Error("expected old password to still be valid after failed change")
}
}
func TestCheckPassword(t *testing.T) {
service, store := setupTestService()
@ -290,7 +350,7 @@ func TestCheckPassword(t *testing.T) {
t.Fatalf("expected no error, got %v", err)
}
t.Run("CheckPassword with correct current password", func(t *testing.T) {
t.Run("correct current password", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Current: "OldPassword123!",
Password: "NewPa$$w0rd",
@ -302,7 +362,7 @@ func TestCheckPassword(t *testing.T) {
}
})
t.Run("CheckPassword with incorrect current password", func(t *testing.T) {
t.Run("incorrect current password", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Current: "WrongPassword123!",
Password: "NewPa$$w0rd",
@ -313,6 +373,16 @@ func TestCheckPassword(t *testing.T) {
t.Error("Expected error for incorrect current password")
}
})
t.Run("correct current but weak new password", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Current: "OldPassword123!",
Password: "weak",
PasswordConfirm: "weak",
}
err := service.CheckPassword(user, form)
requireValidationError(t, err)
})
}
func TestCheckNewPassword(t *testing.T) {
@ -322,7 +392,7 @@ func TestCheckNewPassword(t *testing.T) {
Email: "test@example.com",
}
t.Run("CheckNewPassword with matching passwords", func(t *testing.T) {
t.Run("matching passwords", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Password: "NewPa$$w0rd",
PasswordConfirm: "NewPa$$w0rd",
@ -333,14 +403,23 @@ func TestCheckNewPassword(t *testing.T) {
}
})
t.Run("CheckNewPassword with non-matching passwords", func(t *testing.T) {
t.Run("non-matching passwords", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Password: "NewPa$$w0rd",
PasswordConfirm: "DifferentPassword123!",
}
err := service.CheckNewPassword(user, form)
if err == nil {
t.Error("Expected error for non-matching passwords")
requireValidationError(t, err)
})
t.Run("empty confirmation is accepted", func(t *testing.T) {
form := happydns.ChangePasswordForm{
Password: "NewPa$$w0rd",
PasswordConfirm: "",
}
err := service.CheckNewPassword(user, form)
if err != nil {
t.Fatalf("Expected empty confirmation to be accepted, got %v", err)
}
})
}
@ -358,7 +437,7 @@ func TestDeleteAuthUser(t *testing.T) {
return nil
},
}
service := authuser.NewAuthUserUsecases(cfg, nil, store, mockCloseSessions)
service := authuser.NewAuthUserUsecases(cfg, &NoopMailer{}, store, mockCloseSessions)
user := &happydns.UserAuth{
Email: "test@example.com",
@ -370,24 +449,24 @@ func TestDeleteAuthUser(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
t.Run("DeleteAuthUser with invalid password", func(t *testing.T) {
t.Run("invalid password", func(t *testing.T) {
err := service.DeleteAuthUser(user, "WrongPassword")
if err == nil || err.Error() != "invalid current password" {
t.Errorf("Expected error 'invalid current password', got %v", err)
if err == nil {
t.Error("expected error for invalid password")
}
})
t.Run("DeleteAuthUser with error in closing sessions", func(t *testing.T) {
t.Run("error in closing sessions", func(t *testing.T) {
mockCloseSessions.CloseAllFunc = func(user happydns.UserInfo) error {
return fmt.Errorf("error closing sessions")
}
err := service.DeleteAuthUser(user, "TestPassword123!")
if err == nil || err.Error() != "unable to delete user sessions: error closing sessions" {
t.Errorf("Expected error 'unable to delete user sessions: error closing sessions', got %v", err)
if err == nil {
t.Error("expected error when session close fails")
}
})
t.Run("DeleteAuthUser successful deletion", func(t *testing.T) {
t.Run("successful deletion", func(t *testing.T) {
mockCloseSessions.CloseAllFunc = func(user happydns.UserInfo) error {
return nil
}
@ -395,5 +474,500 @@ func TestDeleteAuthUser(t *testing.T) {
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
// Verify user is gone
_, err = store.GetAuthUser(user.Id)
if err == nil {
t.Error("expected error when fetching deleted user")
}
})
}
// ========== GenRegistrationHash Tests ==========
func TestGenRegistrationHash_Deterministic(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
key := []byte("test-recovery-key-for-registration-hash-0123456")
hash1 := authuser.GenRegistrationHash(createdAt, key, false)
hash2 := authuser.GenRegistrationHash(createdAt, key, false)
if hash1 == "" {
t.Fatal("expected non-empty hash")
}
if hash1 != hash2 {
t.Error("expected identical hashes for same input and time period")
}
}
func TestGenRegistrationHash_EmptyKey(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
hash := authuser.GenRegistrationHash(createdAt, nil, false)
if hash != "" {
t.Errorf("expected empty hash for nil key, got %q", hash)
}
hash = authuser.GenRegistrationHash(createdAt, []byte{}, false)
if hash != "" {
t.Errorf("expected empty hash for empty key, got %q", hash)
}
}
func TestGenRegistrationHash_DifferentPeriods(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
key := []byte("test-recovery-key-for-registration-hash-0123456")
current := authuser.GenRegistrationHash(createdAt, key, false)
previous := authuser.GenRegistrationHash(createdAt, key, true)
if current == "" || previous == "" {
t.Error("expected non-empty hashes for both periods")
}
}
func TestGenRegistrationHash_DifferentCreatedAt(t *testing.T) {
key := []byte("shared-key-for-different-createdat-test-1234567")
createdAt1 := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
createdAt2 := time.Date(2025, 6, 20, 14, 0, 0, 0, time.UTC)
hash1 := authuser.GenRegistrationHash(createdAt1, key, false)
hash2 := authuser.GenRegistrationHash(createdAt2, key, false)
if hash1 == hash2 {
t.Error("expected different hashes for different CreatedAt")
}
}
func TestGenRegistrationHash_DifferentKeys(t *testing.T) {
createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
key1 := []byte("key-one-for-registration-hash-different-keys-test")
key2 := []byte("key-two-for-registration-hash-different-keys-test")
hash1 := authuser.GenRegistrationHash(createdAt, key1, false)
hash2 := authuser.GenRegistrationHash(createdAt, key2, false)
if hash1 == hash2 {
t.Error("expected different hashes for different keys")
}
}
// ========== GenAccountRecoveryHash Tests ==========
func TestGenAccountRecoveryHash_Deterministic(t *testing.T) {
key := []byte("some-secret-recovery-key-for-testing-1234567890")
hash1 := authuser.GenAccountRecoveryHash(key, false)
hash2 := authuser.GenAccountRecoveryHash(key, false)
if hash1 == "" {
t.Fatal("expected non-empty hash")
}
if hash1 != hash2 {
t.Error("expected identical hashes for same key and time period")
}
}
func TestGenAccountRecoveryHash_EmptyKey(t *testing.T) {
hash := authuser.GenAccountRecoveryHash(nil, false)
if hash != "" {
t.Errorf("expected empty hash for nil key, got %q", hash)
}
hash = authuser.GenAccountRecoveryHash([]byte{}, false)
if hash != "" {
t.Errorf("expected empty hash for empty key, got %q", hash)
}
}
func TestGenAccountRecoveryHash_DifferentKeys(t *testing.T) {
key1 := []byte("key-one-for-testing-recovery-hash-generation")
key2 := []byte("key-two-for-testing-recovery-hash-generation")
hash1 := authuser.GenAccountRecoveryHash(key1, false)
hash2 := authuser.GenAccountRecoveryHash(key2, false)
if hash1 == hash2 {
t.Error("expected different hashes for different keys")
}
}
// ========== CanRecoverAccount Tests ==========
func TestCanRecoverAccount_ValidKey(t *testing.T) {
key := []byte("recovery-key-for-can-recover-test-1234567890ab")
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: key,
}
validHash := authuser.GenAccountRecoveryHash(key, false)
err := authuser.CanRecoverAccount(user, validHash)
if err != nil {
t.Fatalf("expected valid key to be accepted, got %v", err)
}
}
func TestCanRecoverAccount_PreviousPeriodKey(t *testing.T) {
key := []byte("recovery-key-for-previous-period-test-12345678")
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: key,
}
previousHash := authuser.GenAccountRecoveryHash(key, true)
err := authuser.CanRecoverAccount(user, previousHash)
if err != nil {
t.Fatalf("expected previous-period key to be accepted, got %v", err)
}
}
func TestCanRecoverAccount_InvalidKey(t *testing.T) {
key := []byte("recovery-key-for-invalid-key-test-1234567890ab")
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: key,
}
err := authuser.CanRecoverAccount(user, "totally-invalid-key")
if err == nil {
t.Error("expected error for invalid recovery key")
}
}
func TestCanRecoverAccount_NilRecoveryKey(t *testing.T) {
user := &happydns.UserAuth{
Email: "test@example.com",
PasswordRecoveryKey: nil,
}
err := authuser.CanRecoverAccount(user, "any-key")
if err == nil {
t.Error("expected error when user has no recovery key")
}
}
// ========== Email Validation Flow Tests ==========
func TestEmailValidation_GenerateLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
link, err := service.GenerateValidationLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if link == "" {
t.Fatal("expected non-empty validation link")
}
if !strings.Contains(link, "/email-validation") {
t.Errorf("expected link to contain /email-validation, got %s", link)
}
if !strings.Contains(link, "u=") || !strings.Contains(link, "k=") {
t.Errorf("expected link to contain u= and k= parameters, got %s", link)
}
}
func TestEmailValidation_ValidateSuccess(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
if user.EmailVerification != nil {
t.Fatal("expected EmailVerification to be nil before validation")
}
// Ensure recovery key exists (GenerateValidationLink generates it as side effect)
_, err = service.GenerateValidationLink(user)
if err != nil {
t.Fatalf("failed to generate validation link: %v", err)
}
key := authuser.GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, false)
err = service.ValidateEmail(user, happydns.AddressValidationForm{Key: key})
if err != nil {
t.Fatalf("expected validation to succeed, got %v", err)
}
if user.EmailVerification == nil {
t.Error("expected EmailVerification to be set after validation")
}
}
func TestEmailValidation_ValidateWithPreviousPeriodKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate-prev@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateValidationLink(user)
if err != nil {
t.Fatalf("failed to generate validation link: %v", err)
}
key := authuser.GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, true)
err = service.ValidateEmail(user, happydns.AddressValidationForm{Key: key})
if err != nil {
t.Fatalf("expected previous-period key to be accepted, got %v", err)
}
}
func TestEmailValidation_ValidateInvalidKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "validate-bad@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
err = service.ValidateEmail(user, happydns.AddressValidationForm{Key: "invalid-key"})
requireValidationError(t, err)
if user.EmailVerification != nil {
t.Error("expected EmailVerification to remain nil after failed validation")
}
}
// ========== Recovery Flow Tests ==========
func TestRecovery_GenerateLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "recover@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
link, err := service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if link == "" {
t.Fatal("expected non-empty recovery link")
}
if !strings.Contains(link, "/forgotten-password") {
t.Errorf("expected link to contain /forgotten-password, got %s", link)
}
if !strings.Contains(link, "u=") || !strings.Contains(link, "k=") {
t.Errorf("expected link to contain u= and k= parameters, got %s", link)
}
if user.PasswordRecoveryKey == nil {
t.Error("expected PasswordRecoveryKey to be set after generating link")
}
}
func TestRecovery_GenerateLinkIdempotent(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "recover-idem@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
link1, err := service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error on first call, got %v", err)
}
link2, err := service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error on second call, got %v", err)
}
if link1 != link2 {
t.Error("expected same link for repeated calls (key already exists)")
}
}
func TestRecovery_ResetPasswordSuccess(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
key := authuser.GenAccountRecoveryHash(user.PasswordRecoveryKey, false)
newPassword := "NewPa$$w0rd99"
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: key,
Password: newPassword,
})
if err != nil {
t.Fatalf("expected password reset to succeed, got %v", err)
}
if !user.CheckPassword(newPassword) {
t.Error("expected new password to work after reset")
}
}
func TestRecovery_ResetPasswordInvalidKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset-bad@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: "invalid-key",
Password: "NewPa$$w0rd99",
})
if err == nil {
t.Error("expected error for invalid recovery key")
}
if !user.CheckPassword("OldPassword123!") {
t.Error("expected old password to still work after failed reset")
}
}
func TestRecovery_ResetPasswordWeakNewPassword(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset-weak@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
key := authuser.GenAccountRecoveryHash(user.PasswordRecoveryKey, false)
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: key,
Password: "weak",
})
requireValidationError(t, err)
}
func TestRecovery_ResetPasswordInvalidatesKey(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "reset-invalidate@example.com",
Password: "OldPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = service.GenerateRecoveryLink(user)
if err != nil {
t.Fatalf("failed to generate recovery link: %v", err)
}
key := authuser.GenAccountRecoveryHash(user.PasswordRecoveryKey, false)
err = service.ResetPassword(user, happydns.AccountRecoveryForm{
Key: key,
Password: "NewPa$$w0rd99",
})
if err != nil {
t.Fatalf("expected first reset to succeed, got %v", err)
}
// DefinePassword clears PasswordRecoveryKey, so the same key should no longer work
if user.PasswordRecoveryKey != nil {
t.Error("expected PasswordRecoveryKey to be nil after password reset")
}
err = authuser.CanRecoverAccount(user, key)
if err == nil {
t.Error("expected recovery key to be invalidated after successful reset")
}
}
// ========== SendRecoveryLink Tests ==========
func TestSendRecoveryLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "send-recover@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
err = service.SendRecoveryLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if user.PasswordRecoveryKey == nil {
t.Error("expected PasswordRecoveryKey to be set after sending recovery link")
}
}
// ========== SendValidationLink Tests ==========
func TestSendValidationLink(t *testing.T) {
service, _ := setupTestService()
user, err := service.CreateAuthUser(happydns.UserRegistration{
Email: "send-validate@example.com",
Password: "StrongPassword123!",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
err = service.SendValidationLink(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}

View file

@ -27,9 +27,7 @@ import (
"crypto/sha512"
"encoding/base64"
"fmt"
"log"
"net/mail"
"reflect"
"time"
"git.happydns.org/happyDomain/internal/helpers"
@ -111,11 +109,6 @@ func (uc *RecoverAccountUsecase) SendLink(user *happydns.UserAuth) error {
toName := helpers.GenUsername(user.Email)
if uc.mailer == nil || reflect.ValueOf(uc.mailer).IsNil() {
log.Printf("No mailer configured. Recovery link for %s: %s", user.Email, link)
return nil
}
return uc.mailer.SendMail(
&mail.Address{Name: toName, Address: user.Email},
"Recover your happyDomain account",

View file

@ -23,11 +23,11 @@ package authuser
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"fmt"
"net/mail"
"reflect"
"time"
"git.happydns.org/happyDomain/internal/helpers"
@ -38,18 +38,24 @@ import (
const RegistrationHashValidity = 24 * time.Hour
// GenRegistrationHash generates the validation hash for the current or previous period.
// The hash computation is based on some already filled fields in the structure.
func GenRegistrationHash(u *happydns.UserAuth, previous bool) string {
// The hash uses both CreatedAt and PasswordRecoveryKey as HMAC key material,
// ensuring the hash cannot be forged without knowledge of the secret recovery key.
func GenRegistrationHash(createdAt time.Time, recoveryKey []byte, previous bool) string {
if len(recoveryKey) == 0 {
return ""
}
date := time.Now()
if previous {
date = date.Add(RegistrationHashValidity * -1)
}
date = date.Truncate(RegistrationHashValidity)
h := hmac.New(
sha512.New,
[]byte(u.CreatedAt.Format(time.RFC3339Nano)),
)
// Combine CreatedAt and PasswordRecoveryKey as key material.
// This differentiates from GenAccountRecoveryHash which uses only recoveryKey.
keyMaterial := append([]byte(createdAt.Format(time.RFC3339Nano)), recoveryKey...)
h := hmac.New(sha512.New, keyMaterial)
h.Write(date.AppendFormat([]byte{}, time.RFC3339))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
@ -70,16 +76,41 @@ func NewEmailValidationUsecase(store AuthUserStorage, mailer happydns.Mailer, co
}
}
// GenerateLink returns the absolute URL corresponding to the recovery
// URL of the given account.
func (uc *EmailValidationUsecase) GenerateLink(user *happydns.UserAuth) string {
return uc.config.GetBaseURL() + fmt.Sprintf("/email-validation?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(user.Id), GenRegistrationHash(user, false))
// GenerateLink returns the absolute URL corresponding to the email
// validation URL of the given account. It generates a PasswordRecoveryKey
// if one does not already exist.
func (uc *EmailValidationUsecase) GenerateLink(user *happydns.UserAuth) (string, error) {
if err := uc.ensureRecoveryKey(user); err != nil {
return "", err
}
hash := GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, false)
return uc.config.GetBaseURL() + fmt.Sprintf("/email-validation?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(user.Id), hash), nil
}
// ensureRecoveryKey generates and persists a PasswordRecoveryKey if the user doesn't have one.
func (uc *EmailValidationUsecase) ensureRecoveryKey(user *happydns.UserAuth) error {
if user.PasswordRecoveryKey != nil {
return nil
}
user.PasswordRecoveryKey = make([]byte, 64)
if _, err := rand.Read(user.PasswordRecoveryKey); err != nil {
return fmt.Errorf("unable to generate recovery key: %w", err)
}
if err := uc.store.UpdateAuthUser(user); err != nil {
return fmt.Errorf("unable to save recovery key: %w", err)
}
return nil
}
// SendLink sends an email validation link to the user's email.
func (uc *EmailValidationUsecase) SendLink(user *happydns.UserAuth) error {
if uc.mailer == nil || reflect.ValueOf(uc.mailer).IsNil() {
return fmt.Errorf("no mailer configured")
link, err := uc.GenerateLink(user)
if err != nil {
return fmt.Errorf("unable to generate validation link: %w", err)
}
toName := helpers.GenUsername(user.Email)
@ -98,13 +129,15 @@ management platform!
In order to validate your account, please follow this link now:
[Validate my account](%s)
`, toName, uc.GenerateLink(user)),
`, toName, link),
)
}
// Validate tries to validate the email address by comparing the given key to the expected one.
func (uc *EmailValidationUsecase) Validate(user *happydns.UserAuth, form happydns.AddressValidationForm) error {
if form.Key != GenRegistrationHash(user, false) && form.Key != GenRegistrationHash(user, true) {
currentHash := GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, false)
previousHash := GenRegistrationHash(user.CreatedAt, user.PasswordRecoveryKey, true)
if currentHash == "" || (form.Key != currentHash && form.Key != previousHash) {
return happydns.ValidationError{Msg: fmt.Sprintf("bad email validation key: the validation address link you follow is invalid or has expired (it is valid during %d hours)", RegistrationHashValidity/time.Hour)}
}

View file

@ -122,7 +122,7 @@ func createTestProvider(t *testing.T, store storage.Storage, user *happydns.User
func setupTestService(store storage.Storage) (*domain.Service, *mockDomainLogAppender) {
// Create the provider service
providerService := providerUC.NewService(store)
providerService := providerUC.NewService(store, nil)
// Create the zone usecase
getZone := zoneUC.NewGetZoneUsecase(store)

View file

@ -0,0 +1,37 @@
// 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 domainlog
import (
"log"
"git.happydns.org/happyDomain/model"
)
// NoopDomainLogAppender is a fallback implementation of DomainLogAppender
// that prints log entries to stdout instead of persisting them.
type NoopDomainLogAppender struct{}
func (NoopDomainLogAppender) AppendDomainLog(domain *happydns.Domain, entry *happydns.DomainLog) error {
log.Printf("domain=%s %s\n", domain.DomainName, entry.Content)
return nil
}

View file

@ -27,6 +27,8 @@
package orchestrator
import (
"context"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
@ -44,12 +46,12 @@ type ProviderGetter interface {
// ZoneRetriever is an interface for retrieving zones from providers.
type ZoneRetriever interface {
RetrieveZone(provider *happydns.Provider, name string) ([]happydns.Record, error)
RetrieveZone(ctx context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error)
}
// ZoneCorrector is an interface for getting zone corrections.
type ZoneCorrector interface {
ListZoneCorrections(provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error)
ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error)
}
// Orchestrator aggregates the use-cases that together implement the DNS zone

View file

@ -22,7 +22,9 @@
package orchestrator
import (
"context"
"fmt"
"log"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/model"
@ -34,7 +36,7 @@ import (
type RemoteZoneImporterUsecase struct {
appendDomainLog domainlogUC.DomainLogAppender
providerService ProviderGetter
zoneImporter *ZoneImporterUsecase
zoneImporter happydns.ZoneImporterUsecase
zoneRetriever ZoneRetriever
}
@ -43,7 +45,7 @@ type RemoteZoneImporterUsecase struct {
func NewRemoteZoneImporterUsecase(
appendDomainLog domainlogUC.DomainLogAppender,
providerService ProviderGetter,
zoneImporter *ZoneImporterUsecase,
zoneImporter happydns.ZoneImporterUsecase,
zoneRetriever ZoneRetriever,
) *RemoteZoneImporterUsecase {
return &RemoteZoneImporterUsecase{
@ -57,25 +59,24 @@ func NewRemoteZoneImporterUsecase(
// Import resolves the provider for the domain, retrieves its current records,
// and imports them via ZoneImporterUsecase. A domain log entry is appended on
// success. Returns the newly created zone or an error.
func (uc *RemoteZoneImporterUsecase) Import(user *happydns.User, domain *happydns.Domain) (*happydns.Zone, error) {
func (uc *RemoteZoneImporterUsecase) Import(ctx context.Context, user *happydns.User, domain *happydns.Domain) (*happydns.Zone, error) {
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
if err != nil {
return nil, err
}
zone, err := uc.zoneRetriever.RetrieveZone(provider, domain.DomainName)
zone, err := uc.zoneRetriever.RetrieveZone(ctx, provider, domain.DomainName)
if err != nil {
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to retrieve the zone from server: %s", err.Error())}
return nil, fmt.Errorf("unable to retrieve the zone from server: %w", err)
}
// import
myZone, err := uc.zoneImporter.Import(user, domain, zone)
if err != nil {
return nil, err
}
if uc.appendDomainLog != nil {
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Zone imported from provider API: %s", myZone.Id.String())))
if err := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Zone imported from provider API: %s", myZone.Id.String()))); err != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, err.Error())
}
return myZone, nil

View file

@ -22,6 +22,7 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"log"
@ -72,12 +73,13 @@ func NewZoneCorrectionApplierUsecase(
// zone is prepended to the domain's history so future edits start from the
// published state. Returns the newly created zone or a descriptive error.
func (uc *ZoneCorrectionApplierUsecase) Apply(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
form *happydns.ApplyZoneForm,
) (*happydns.Zone, error) {
corrections, _, err := uc.List(user, domain, zone)
corrections, _, err := uc.List(ctx, user, domain, zone)
if err != nil {
return nil, err
}
@ -101,7 +103,9 @@ corrections:
if corrErr != nil {
log.Printf("%s: unable to apply correction: %s", domain.DomainName, corrErr.Error())
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed record update (%s): %s", cr.Msg, corrErr.Error())))
if logErr := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed record update (%s): %s", cr.Msg, corrErr.Error()))); logErr != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
errs = errors.Join(errs, fmt.Errorf("%s: %w", cr.Msg, corrErr))
// Stop if no corrections have been successfully applied yet
if appliedCount == 0 {
@ -124,14 +128,20 @@ corrections:
}
if errs != nil {
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed zone publishing (%s): %d of %d corrections applied, errors occurred.", zone.Id.String(), appliedCount, nbcorrections)))
if logErr := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed zone publishing (%s): %d of %d corrections applied, errors occurred.", zone.Id.String(), appliedCount, nbcorrections))); logErr != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to update the zone (%d of %d corrections applied): %s", appliedCount, nbcorrections, errs.Error())}
} else if unmatchedCount > 0 {
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed zone publishing (%s): %d corrections were not found in the current diff.", zone.Id.String(), unmatchedCount)))
if logErr := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("Failed zone publishing (%s): %d corrections were not found in the current diff.", zone.Id.String(), unmatchedCount))); logErr != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to perform %d corrections that were not found in the current diff", unmatchedCount)}
}
uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ACK, fmt.Sprintf("Zone published (%s), %d corrections applied with success", zone.Id.String(), nbcorrections)))
if logErr := uc.appendDomainLog.AppendDomainLog(domain, happydns.NewDomainLog(user, happydns.LOG_ACK, fmt.Sprintf("Zone published (%s), %d corrections applied with success", zone.Id.String(), nbcorrections))); logErr != nil {
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
// Create a new zone in history for further updates
newZone := zone.DerivateNew()

View file

@ -22,6 +22,8 @@
package orchestrator
import (
"context"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
)
@ -55,6 +57,7 @@ func NewZoneCorrectionListerUsecase(
// computation to the ZoneCorrector. The second return value is the total
// number of corrections before any filtering.
func (uc *ZoneCorrectionListerUsecase) List(
ctx context.Context,
user *happydns.User,
domain *happydns.Domain,
zone *happydns.Zone,
@ -69,5 +72,5 @@ func (uc *ZoneCorrectionListerUsecase) List(
return nil, 0, err
}
return uc.zoneCorrector.ListZoneCorrections(provider, domain, records)
return uc.zoneCorrector.ListZoneCorrections(ctx, provider, domain, records)
}

View file

@ -22,12 +22,13 @@
package orchestrator_test
import (
"context"
"errors"
"testing"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
"git.happydns.org/happyDomain/model"
)
@ -48,7 +49,7 @@ type mockZoneCorrector struct {
err error
}
func (m *mockZoneCorrector) ListZoneCorrections(_ *happydns.Provider, _ *happydns.Domain, _ []happydns.Record) ([]*happydns.Correction, int, error) {
func (m *mockZoneCorrector) ListZoneCorrections(_ context.Context, _ *happydns.Provider, _ *happydns.Domain, _ []happydns.Record) ([]*happydns.Correction, int, error) {
return m.corrections, m.nbDiff, m.err
}
@ -80,7 +81,7 @@ func TestZoneCorrectionLister_List_Success(t *testing.T) {
Services: map[happydns.Subdomain][]*happydns.Service{},
}
got, nbDiff, err := uc.List(user, domain, zone)
got, nbDiff, err := uc.List(context.Background(), user, domain, zone)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -118,7 +119,7 @@ func TestZoneCorrectionLister_List_ProviderError(t *testing.T) {
ZoneMeta: happydns.ZoneMeta{DefaultTTL: 3600},
}
_, _, err := uc.List(user, domain, zone)
_, _, err := uc.List(context.Background(), user, domain, zone)
if err == nil {
t.Fatal("expected error, got nil")
}
@ -146,7 +147,7 @@ func TestZoneCorrectionLister_List_ZoneCorrectorError(t *testing.T) {
Services: map[happydns.Subdomain][]*happydns.Service{},
}
_, _, err := uc.List(user, domain, zone)
_, _, err := uc.List(context.Background(), user, domain, zone)
if err == nil {
t.Fatal("expected error, got nil")
}
@ -172,7 +173,7 @@ func TestZoneCorrectionLister_List_NoCorrections(t *testing.T) {
Services: map[happydns.Subdomain][]*happydns.Service{},
}
got, nbDiff, err := uc.List(user, domain, zone)
got, nbDiff, err := uc.List(context.Background(), user, domain, zone)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

View file

@ -29,9 +29,9 @@ import (
// CreateDomainOnProvider creates a domain on the given provider.
func (s *Service) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
p, err := provider.InstantiateProvider()
p, err := instantiate(provider)
if err != nil {
return fmt.Errorf("unable to instantiate the provider: %w", err)
return err
}
if !p.CanCreateDomain() {
@ -41,20 +41,11 @@ func (s *Service) CreateDomainOnProvider(provider *happydns.Provider, fqdn strin
return p.CreateDomain(fqdn)
}
// CreateDomainOnProvider refuses the operation when DisableProviders is set, otherwise delegates to Service.
func (s *RestrictedService) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
if s.config.DisableProviders {
return happydns.ForbiddenError{Msg: "cannot create domain on provider as DisableProviders parameter is set."}
}
return s.Service.CreateDomainOnProvider(provider, fqdn)
}
// ListHostedDomains lists all domains hosted on the given provider.
func (s *Service) ListHostedDomains(provider *happydns.Provider) ([]string, error) {
p, err := provider.InstantiateProvider()
p, err := instantiate(provider)
if err != nil {
return nil, fmt.Errorf("unable to instantiate the provider: %w", err)
return nil, err
}
if !p.CanListZones() {
@ -66,9 +57,9 @@ func (s *Service) ListHostedDomains(provider *happydns.Provider) ([]string, erro
// TestDomainExistence tests whether a domain exists on the given provider.
func (s *Service) TestDomainExistence(provider *happydns.Provider, name string) error {
instance, err := provider.InstantiateProvider()
instance, err := instantiate(provider)
if err != nil {
return fmt.Errorf("unable to instantiate provider: %w", err)
return err
}
_, err = instance.GetZoneRecords(name)

View file

@ -22,6 +22,7 @@
package provider
import (
"context"
"encoding/json"
"fmt"
@ -35,19 +36,18 @@ type Service struct {
validator ProviderValidator
}
// NewService creates a new Service backed by the given storage.
func NewService(store ProviderStorage) *Service {
// NewService creates a new provider Service. If validator is nil,
// the DefaultProviderValidator is used.
func NewService(store ProviderStorage, validator ProviderValidator) *Service {
if validator == nil {
validator = &DefaultProviderValidator{}
}
return &Service{
store: store,
validator: &DefaultProviderValidator{},
validator: validator,
}
}
// SetValidator allows replacing the validator (useful for testing).
func (s *Service) SetValidator(v ProviderValidator) {
s.validator = v
}
// ParseProvider converts a ProviderMessage to a Provider.
func ParseProvider(msg *happydns.ProviderMessage) (p *happydns.Provider, err error) {
p = &happydns.Provider{}
@ -62,6 +62,15 @@ func ParseProvider(msg *happydns.ProviderMessage) (p *happydns.Provider, err err
return
}
// instantiate is a helper that instantiates a provider and wraps errors consistently.
func instantiate(p *happydns.Provider) (happydns.ProviderActuator, error) {
instance, err := p.InstantiateProvider()
if err != nil {
return nil, fmt.Errorf("unable to instantiate provider: %w", err)
}
return instance, nil
}
// CreateProvider creates a new provider for the given user.
func (s *Service) CreateProvider(user *happydns.User, msg *happydns.ProviderMessage) (*happydns.Provider, error) {
provider, err := ParseProvider(msg)
@ -123,7 +132,10 @@ func (s *Service) GetUserProviderMeta(user *happydns.User, providerID happydns.I
func (s *Service) ListUserProviders(user *happydns.User) ([]*happydns.ProviderMeta, error) {
items, err := s.store.ListProviders(user)
if err != nil {
return nil, fmt.Errorf("list providers failed: %w", err)
return nil, happydns.InternalError{
Err: fmt.Errorf("failed to list providers: %w", err),
UserMessage: "Sorry, we are currently unable to list your providers. Please try again later.",
}
}
metas := make([]*happydns.ProviderMeta, 0, len(items))
@ -177,22 +189,11 @@ func (s *Service) UpdateProviderFromMessage(providerID happydns.Identifier, user
// DeleteProvider deletes a provider for the given user.
func (s *Service) DeleteProvider(user *happydns.User, providerID happydns.Identifier) error {
// TODO: Find another way to avoid import cycle
// We should verify that no domains are using this provider before deleting
/*domains, err := s.listDomains.List(user)
if err != nil {
return happydns.InternalError{
Err: fmt.Errorf("failed to list domains: %w", err),
UserMessage: "Sorry, we are currently unable to perform this action. Please try again later.",
}
// Verify ownership before deleting
if _, err := s.getUserProvider(user, providerID); err != nil {
return err
}
for _, d := range domains {
if d.ProviderId.Equals(providerID) {
return fmt.Errorf("You cannot delete this provider because it is still used by: %s", d.DomainName)
}
}*/
if err := s.store.DeleteProvider(providerID); err != nil {
return happydns.InternalError{
Err: fmt.Errorf("failed to delete provider %s: %w", providerID.String(), err),
@ -203,18 +204,17 @@ func (s *Service) DeleteProvider(user *happydns.User, providerID happydns.Identi
return nil
}
// RestrictedService wraps Service with configuration-based restrictions.
// RestrictedService wraps a ProviderUsecase with configuration-based restrictions.
type RestrictedService struct {
Service
inner happydns.ProviderUsecase
config *happydns.Options
}
// NewRestrictedService creates a RestrictedService backed by the given configuration and storage.
func NewRestrictedService(cfg *happydns.Options, store ProviderStorage) *RestrictedService {
s := NewService(store)
return &RestrictedService{
*s,
cfg,
inner: NewService(store, nil),
config: cfg,
}
}
@ -224,7 +224,7 @@ func (s *RestrictedService) CreateProvider(user *happydns.User, msg *happydns.Pr
return nil, happydns.ForbiddenError{Msg: "cannot add provider as DisableProviders parameter is set."}
}
return s.Service.CreateProvider(user, msg)
return s.inner.CreateProvider(user, msg)
}
// DeleteProvider refuses the operation when DisableProviders is set, otherwise delegates to Service.
@ -233,7 +233,7 @@ func (s *RestrictedService) DeleteProvider(user *happydns.User, providerID happy
return happydns.ForbiddenError{Msg: "cannot delete provider as DisableProviders parameter is set."}
}
return s.Service.DeleteProvider(user, providerID)
return s.inner.DeleteProvider(user, providerID)
}
// UpdateProvider refuses the operation when DisableProviders is set, otherwise delegates to Service.
@ -242,7 +242,7 @@ func (s *RestrictedService) UpdateProvider(providerID happydns.Identifier, user
return happydns.ForbiddenError{Msg: "cannot update provider as DisableProviders parameter is set."}
}
return s.Service.UpdateProvider(providerID, user, updateFn)
return s.inner.UpdateProvider(providerID, user, updateFn)
}
// UpdateProviderFromMessage refuses the operation when DisableProviders is set, otherwise delegates to Service.
@ -251,5 +251,43 @@ func (s *RestrictedService) UpdateProviderFromMessage(providerID happydns.Identi
return happydns.ForbiddenError{Msg: "cannot update provider as DisableProviders parameter is set."}
}
return s.Service.UpdateProviderFromMessage(providerID, user, p)
return s.inner.UpdateProviderFromMessage(providerID, user, p)
}
func (s *RestrictedService) CreateDomainOnProvider(provider *happydns.Provider, fqdn string) error {
if s.config.DisableProviders {
return happydns.ForbiddenError{Msg: "cannot create domain on provider as DisableProviders parameter is set."}
}
return s.inner.CreateDomainOnProvider(provider, fqdn)
}
// Read-only operations delegate directly.
func (s *RestrictedService) GetUserProvider(user *happydns.User, providerID happydns.Identifier) (*happydns.Provider, error) {
return s.inner.GetUserProvider(user, providerID)
}
func (s *RestrictedService) GetUserProviderMeta(user *happydns.User, providerID happydns.Identifier) (*happydns.ProviderMeta, error) {
return s.inner.GetUserProviderMeta(user, providerID)
}
func (s *RestrictedService) ListUserProviders(user *happydns.User) ([]*happydns.ProviderMeta, error) {
return s.inner.ListUserProviders(user)
}
func (s *RestrictedService) ListHostedDomains(provider *happydns.Provider) ([]string, error) {
return s.inner.ListHostedDomains(provider)
}
func (s *RestrictedService) ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error) {
return s.inner.ListZoneCorrections(ctx, provider, domain, records)
}
func (s *RestrictedService) RetrieveZone(ctx context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error) {
return s.inner.RetrieveZone(ctx, provider, name)
}
func (s *RestrictedService) TestDomainExistence(provider *happydns.Provider, name string) error {
return s.inner.TestDomainExistence(provider, name)
}

View file

@ -74,12 +74,14 @@ func (v *mockValidator) Validate(p *happydns.Provider) error {
return nil
}
func Test_CreateProvider(t *testing.T) {
func newTestService(t *testing.T) (*provider.Service, storage.Storage) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
// Replace validator with mock to avoid actual DNS validation
providerService.SetValidator(&mockValidator{})
return provider.NewService(db, &mockValidator{}), db
}
func Test_CreateProvider(t *testing.T) {
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test DDNS Provider")
@ -110,10 +112,7 @@ func Test_CreateProvider(t *testing.T) {
}
func Test_GetUserProvider(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -139,10 +138,7 @@ func Test_GetUserProvider(t *testing.T) {
}
func Test_GetUserProvider_WrongUser(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
@ -165,9 +161,7 @@ func Test_GetUserProvider_WrongUser(t *testing.T) {
}
func Test_GetUserProvider_NotFound(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -182,10 +176,7 @@ func Test_GetUserProvider_NotFound(t *testing.T) {
}
func Test_GetUserProviderMeta(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -211,10 +202,7 @@ func Test_GetUserProviderMeta(t *testing.T) {
}
func Test_ListUserProviders(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -244,10 +232,7 @@ func Test_ListUserProviders(t *testing.T) {
}
func Test_ListUserProviders_MultipleUsers(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
@ -288,10 +273,7 @@ func Test_ListUserProviders_MultipleUsers(t *testing.T) {
}
func Test_UpdateProvider(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -321,10 +303,7 @@ func Test_UpdateProvider(t *testing.T) {
}
func Test_UpdateProvider_PreventIdChange(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -349,10 +328,7 @@ func Test_UpdateProvider_PreventIdChange(t *testing.T) {
}
func Test_UpdateProvider_WrongUser(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
@ -374,10 +350,7 @@ func Test_UpdateProvider_WrongUser(t *testing.T) {
}
func Test_DeleteProvider(t *testing.T) {
mem, _ := inmemory.NewInMemoryStorage()
db, _ := kv.NewKVDatabase(mem)
providerService := provider.NewService(db)
providerService.SetValidator(&mockValidator{})
providerService, db := newTestService(t)
user := createTestUser(t, db, "test@example.com")
@ -404,6 +377,35 @@ func Test_DeleteProvider(t *testing.T) {
}
}
func Test_DeleteProvider_WrongUser(t *testing.T) {
providerService, db := newTestService(t)
user1 := createTestUser(t, db, "user1@example.com")
user2 := createTestUser(t, db, "user2@example.com")
// Create a provider for user1
msg := createTestProviderMessage(t, "DDNSServer", "User1 Provider")
createdProvider, err := providerService.CreateProvider(user1, msg)
if err != nil {
t.Fatalf("unexpected error creating provider: %v", err)
}
// Try to delete the provider as user2
err = providerService.DeleteProvider(user2, createdProvider.Id)
if err == nil {
t.Error("expected error when deleting another user's provider")
}
if err != happydns.ErrProviderNotFound {
t.Errorf("expected ErrProviderNotFound, got %v", err)
}
// Verify the provider still exists for user1
_, err = providerService.GetUserProvider(user1, createdProvider.Id)
if err != nil {
t.Errorf("provider should still exist for user1, got error: %v", err)
}
}
func Test_ParseProvider(t *testing.T) {
msg := createTestProviderMessage(t, "DDNSServer", "Test Parse")
@ -462,8 +464,7 @@ func Test_RestrictedService_UpdateProvider_Disabled(t *testing.T) {
db, _ := kv.NewKVDatabase(mem)
// First create a provider without restrictions
unrestricted := provider.NewService(db)
unrestricted.SetValidator(&mockValidator{})
unrestricted := provider.NewService(db, &mockValidator{})
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := unrestricted.CreateProvider(user, msg)
@ -493,8 +494,7 @@ func Test_RestrictedService_DeleteProvider_Disabled(t *testing.T) {
db, _ := kv.NewKVDatabase(mem)
// First create a provider without restrictions
unrestricted := provider.NewService(db)
unrestricted.SetValidator(&mockValidator{})
unrestricted := provider.NewService(db, &mockValidator{})
user := createTestUser(t, db, "test@example.com")
msg := createTestProviderMessage(t, "DDNSServer", "Test Provider")
createdProvider, err := unrestricted.CreateProvider(user, msg)

View file

@ -22,26 +22,26 @@
package provider
import (
"fmt"
"context"
"git.happydns.org/happyDomain/model"
)
// RetrieveZone retrieves the current zone records for the given domain from the provider.
func (s *Service) RetrieveZone(provider *happydns.Provider, name string) ([]happydns.Record, error) {
instance, err := provider.InstantiateProvider()
func (s *Service) RetrieveZone(_ context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error) {
instance, err := instantiate(provider)
if err != nil {
return nil, fmt.Errorf("unable to instantiate provider: %w", err)
return nil, err
}
return instance.GetZoneRecords(name)
}
// ListZoneCorrections lists the corrections needed to synchronize the zone with the given records.
func (s *Service) ListZoneCorrections(provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error) {
instance, err := provider.InstantiateProvider()
func (s *Service) ListZoneCorrections(_ context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error) {
instance, err := instantiate(provider)
if err != nil {
return nil, 0, fmt.Errorf("unable to instantiate provider: %w", err)
return nil, 0, err
}
return instance.GetZoneCorrections(domain.DomainName, records)

View file

@ -22,24 +22,22 @@
package usecase
import (
"encoding/json"
"fmt"
"git.happydns.org/happyDomain/internal/forms"
"git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/model"
)
type providerSettingsUsecase struct {
config *happydns.Options
providerService happydns.ProviderUsecase
store provider.ProviderStorage
}
func NewProviderSettingsUsecase(cfg *happydns.Options, ps happydns.ProviderUsecase, store provider.ProviderStorage) happydns.ProviderSettingsUsecase {
func NewProviderSettingsUsecase(cfg *happydns.Options, ps happydns.ProviderUsecase) happydns.ProviderSettingsUsecase {
return &providerSettingsUsecase{
config: cfg,
providerService: ps,
store: store,
}
}
@ -55,56 +53,43 @@ func (psu *providerSettingsUsecase) NextProviderSettingsState(state *happydns.Pr
return nil, nil, happydns.ForbiddenError{Msg: "cannot change provider settings as DisableProviders parameter is set."}
}
p, err := state.ProviderBody.InstantiateProvider()
providerJSON, err := json.Marshal(state.ProviderBody)
if err != nil {
return nil, nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to instantiate provider: %s", err.Error())}
return nil, nil, happydns.InternalError{
Err: fmt.Errorf("unable to marshal provider body: %w", err),
UserMessage: happydns.TryAgainErr,
}
}
if p.CanListZones() {
if _, err = p.ListZones(); err != nil {
return nil, nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to list provider's zones: %s", err.Error())}
}
msg := &happydns.ProviderMessage{
ProviderMeta: happydns.ProviderMeta{
Type: pType,
Comment: state.Name,
},
Provider: providerJSON,
}
if state.Id == nil {
provider := &happydns.Provider{
Provider: state.ProviderBody,
ProviderMeta: happydns.ProviderMeta{
Type: pType,
Owner: user.Id,
Comment: state.Name,
},
}
// Create a new Provider
err = psu.store.CreateProvider(provider)
// Create a new Provider via the service layer
provider, err := psu.providerService.CreateProvider(user, msg)
if err != nil {
return nil, nil, happydns.InternalError{
Err: fmt.Errorf("unable to CreateProvider: %w", err),
UserMessage: happydns.TryAgainErr,
}
return nil, nil, err
}
return provider, nil, nil
} else {
// Update an existing Provider
p, err := psu.providerService.GetUserProvider(user, *state.Id)
// Update an existing Provider via the service layer
err := psu.providerService.UpdateProviderFromMessage(*state.Id, user, msg)
if err != nil {
return nil, nil, happydns.NotFoundError{Msg: fmt.Sprintf("unable to retrieve the original provider: %s", err.Error())}
return nil, nil, err
}
newp := &happydns.Provider{
ProviderMeta: p.ProviderMeta,
Provider: state.ProviderBody,
}
err = psu.store.UpdateProvider(newp)
provider, err := psu.providerService.GetUserProvider(user, *state.Id)
if err != nil {
return nil, nil, happydns.InternalError{
Err: fmt.Errorf("unable to UpdateProvider: %w", err),
UserMessage: happydns.TryAgainErr,
}
return nil, nil, err
}
return newp, nil, nil
return provider, nil, nil
}
}

View file

@ -33,13 +33,19 @@ import (
"git.happydns.org/happyDomain/services"
)
// serviceSpecsUsecase implements happydns.ServiceSpecsUsecase, providing
// introspection into registered DNS services: listing them, retrieving their
// field specifications, and generating preview DNS records.
type serviceSpecsUsecase struct {
}
// NewServiceSpecsUsecase creates a new ServiceSpecsUsecase.
func NewServiceSpecsUsecase() happydns.ServiceSpecsUsecase {
return &serviceSpecsUsecase{}
}
// ListServices returns metadata (ServiceInfos) for every registered DNS service,
// keyed by service type identifier.
func (ssu *serviceSpecsUsecase) ListServices() map[string]happydns.ServiceInfos {
services := svcs.ListServices()
@ -51,6 +57,9 @@ func (ssu *serviceSpecsUsecase) ListServices() map[string]happydns.ServiceInfos
return ret
}
// GetServiceIcon returns the raw PNG icon bytes for the service identified by
// ssid (with or without the ".png" suffix). Returns NotFoundError if no icon
// is registered for that service.
func (ssu *serviceSpecsUsecase) GetServiceIcon(ssid string) ([]byte, error) {
cnt, ok := svcs.Icons[strings.TrimSuffix(ssid, ".png")]
if !ok {
@ -60,10 +69,18 @@ func (ssu *serviceSpecsUsecase) GetServiceIcon(ssid string) ([]byte, error) {
return cnt, nil
}
// GetServiceSpecs returns the field specifications for a service type,
// describing each configurable field with its type, label, constraints, and
// other UI metadata.
func (ssu *serviceSpecsUsecase) GetServiceSpecs(svctype reflect.Type) (*happydns.ServiceSpecs, error) {
return ssu.getSpecs(svctype)
}
// InitializeService returns a new instance of the service type populated with
// sensible default values. If the service implements ServiceInitializer its
// Initialize method is called; otherwise defaults are derived by reflection:
// slices of scalar types are pre-populated with one empty element, nested
// structs and DNS record types are recursively initialized.
func (ssu *serviceSpecsUsecase) InitializeService(svctype reflect.Type) (any, error) {
// Create a new instance of the service
svcPtr := reflect.New(svctype)
@ -110,6 +127,7 @@ func (ssu *serviceSpecsUsecase) InitializeService(svctype reflect.Type) (any, er
return svc, nil
}
// countSettableFields returns the number of exported, non-anonymous fields in v.
func (ssu *serviceSpecsUsecase) countSettableFields(v reflect.Value) int {
count := 0
for i := 0; i < v.NumField(); i++ {
@ -123,6 +141,10 @@ func (ssu *serviceSpecsUsecase) countSettableFields(v reflect.Value) int {
return count
}
// initializeStructFields recursively initializes exported fields of a struct
// value: slices become empty non-nil slices, maps become empty non-nil maps,
// DNS types are initialized via initializeDNSRecord, and nested structs are
// processed recursively.
func (ssu *serviceSpecsUsecase) initializeStructFields(v reflect.Value) {
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
@ -174,7 +196,9 @@ func (ssu *serviceSpecsUsecase) initializeStructFields(v reflect.Value) {
}
}
// isDNSType checks if a type is from the miekg/dns package or a happyDomain DNS abstraction
// isDNSType reports whether t is a DNS record type — either from the
// github.com/miekg/dns package or a happyDomain model type that embeds a
// dns.RR_Header field named "Hdr".
func (ssu *serviceSpecsUsecase) isDNSType(t reflect.Type) bool {
pkgPath := t.PkgPath()

View file

@ -23,6 +23,7 @@ package user_test
import (
"bytes"
"net/mail"
"testing"
"git.happydns.org/happyDomain/internal/storage"
@ -34,6 +35,13 @@ import (
"git.happydns.org/happyDomain/model"
)
// noopMailer is a mock mailer that discards all emails.
type noopMailer struct{}
func (n *noopMailer) SendMail(to *mail.Address, subject, content string) error {
return nil
}
// Mock implementations for testing
type mockNewsletterSubscriptor struct {
subscribed []happydns.UserInfo
@ -83,7 +91,7 @@ func createTestService(t *testing.T) (*user.Service, storage.Storage, *mockNewsl
DisableRegistration: false,
}
sessionService := sessionUC.NewService(db)
authUserService := authuserUC.NewAuthUserUsecases(cfg, nil, db, sessionService)
authUserService := authuserUC.NewAuthUserUsecases(cfg, &noopMailer{}, db, sessionService)
newsletter := &mockNewsletterSubscriptor{}
sessionCloser := &mockSessionCloser{}

View file

@ -23,6 +23,7 @@ package zone
import (
"fmt"
"strings"
"github.com/miekg/dns"
@ -55,29 +56,30 @@ func (uc *ListRecordsUsecase) ToZoneFile(domain *happydns.Domain, zone *happydns
}
}
var ret string
var ret strings.Builder
for _, rr := range records {
ret += rr.String() + "\n"
ret.WriteString(rr.String())
ret.WriteString("\n")
}
return ret, nil
return ret.String(), nil
}
// List expands every service in zone into its raw DNS records, ensures the SOA
// record is first, and merges SPF contributions into single TXT records per
// domain name.
func (uc *ListRecordsUsecase) List(domain *happydns.Domain, zone *happydns.Zone) (rrs []happydns.Record, err error) {
var svc_rrs []happydns.Record
var svcRRs []happydns.Record
for _, services := range zone.Services {
for _, svc := range services {
svc_rrs, err = uc.serviceListRecordsUC.List(svc, domain.DomainName, zone.DefaultTTL)
svcRRs, err = uc.serviceListRecordsUC.List(svc, domain.DomainName, zone.DefaultTTL)
if err != nil {
return
}
rrs = append(rrs, svc_rrs...)
rrs = append(rrs, svcRRs...)
}
// Ensure SOA is the first record

View file

@ -118,7 +118,7 @@ type AuthUserUsecase interface {
CreateAuthUser(UserRegistration) (*UserAuth, error)
DeleteAuthUser(*UserAuth, string) error
GenerateRecoveryLink(*UserAuth) (string, error)
GenerateValidationLink(*UserAuth) string
GenerateValidationLink(*UserAuth) (string, error)
GetAuthUser(Identifier) (*UserAuth, error)
GetAuthUserByEmail(string) (*UserAuth, error)
ResetPassword(*UserAuth, AccountRecoveryForm) error
@ -128,7 +128,7 @@ type AuthUserUsecase interface {
}
type EmailValidationUsecase interface {
GenerateLink(user *UserAuth) string
GenerateLink(user *UserAuth) (string, error)
SendLink(user *UserAuth) error
Validate(user *UserAuth, form AddressValidationForm) error
}

View file

@ -21,15 +21,15 @@
package happydns
import ()
import "context"
type RemoteZoneImporterUsecase interface {
Import(*User, *Domain) (*Zone, error)
Import(context.Context, *User, *Domain) (*Zone, error)
}
type ZoneCorrectionApplierUsecase interface {
Apply(*User, *Domain, *Zone, *ApplyZoneForm) (*Zone, error)
List(*User, *Domain, *Zone) ([]*Correction, int, error)
Apply(context.Context, *User, *Domain, *Zone, *ApplyZoneForm) (*Zone, error)
List(context.Context, *User, *Domain, *Zone) ([]*Correction, int, error)
}
type ZoneImporterUsecase interface {

View file

@ -22,6 +22,7 @@
package happydns
import (
"context"
"encoding/json"
)
@ -130,8 +131,8 @@ type ProviderUsecase interface {
GetUserProviderMeta(*User, Identifier) (*ProviderMeta, error)
ListHostedDomains(*Provider) ([]string, error)
ListUserProviders(*User) ([]*ProviderMeta, error)
ListZoneCorrections(provider *Provider, domain *Domain, records []Record) ([]*Correction, int, error)
RetrieveZone(*Provider, string) ([]Record, error)
ListZoneCorrections(ctx context.Context, provider *Provider, domain *Domain, records []Record) ([]*Correction, int, error)
RetrieveZone(context.Context, *Provider, string) ([]Record, error)
TestDomainExistence(*Provider, string) error
UpdateProvider(Identifier, *User, func(*Provider)) error
UpdateProviderFromMessage(Identifier, *User, *ProviderMessage) error

View file

@ -30,7 +30,7 @@ import (
type AutoDNSAPI struct {
Username string `json:"username,omitempty" happydomain:"label=Username,placeholder=autodns.service-account@example.com,required,description=Your AutoDNS user name."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,description=Your AutoDNS password."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,secret,description=Your AutoDNS password."`
Context string `json:"context,omitempty" happydomain:"label=Context,placeholder=33004,description=Your AutoDNS context."`
}

View file

@ -29,10 +29,10 @@ import (
)
type AkamaiEdgeDnsAPI struct {
ClientSecret string `json:"clientsecret,omitempty" happydomain:"label=Client Secret,placeholder=xxxxxxxx,required,description=Your Akamai Client Secret (You must enable API-Access for your account)."`
ClientSecret string `json:"clientsecret,omitempty" happydomain:"label=Client Secret,placeholder=xxxxxxxx,required,secret,description=Your Akamai Client Secret (You must enable API-Access for your account)."`
Host string `json:"host,omitempty" happydomain:"label=Host,placeholder=akaa-xxxxxxxxxxx.xxxx.akamaiapis.net,required,description=Your Akamai Host."`
AccessToken string `json:"accesstoken,omitempty" happydomain:"label=Access Token,placeholder=akaa-xxxxxxxxxxx,description=Your Akamai Access Token."`
ClientToken string `json:"clienttoken,omitempty" happydomain:"label=Client Token,placeholder=akaa-xxxxxxxxxxx,description=Your Akamai Client Token"`
AccessToken string `json:"accesstoken,omitempty" happydomain:"label=Access Token,placeholder=akaa-xxxxxxxxxxx,secret,description=Your Akamai Access Token."`
ClientToken string `json:"clienttoken,omitempty" happydomain:"label=Client Token,placeholder=akaa-xxxxxxxxxxx,secret,description=Your Akamai Client Token"`
ContractId string `json:"contractid,omitempty" happydomain:"label=Contract ID,placeholder=X-XXXX,description=Your Akamai Contract ID."`
GroupId string `json:"groupId,omitempty" happydomain:"label=Group ID,placeholder=NNNNNN,description=Your Akamai Group ID."`
}

View file

@ -33,7 +33,7 @@ type AzureDnsAPI struct {
ResourceGroup string `json:"ResourceGroup,omitempty" happydomain:"label=Resource Group,placeholder=xxxxxxxx,required,description=Your Azure Resource Group."`
TenantID string `json:"TenantID,omitempty" happydomain:"label=Tenant ID,placeholder=xxxxxxxx,description=Your Azure Tenant ID."`
ClientID string `json:"ClientID,omitempty" happydomain:"label=Client ID,placeholder=xxxxxxxx,description=Your Azure Client ID."`
ClientSecret string `json:"ClientSecret,omitempty" happydomain:"label=Client Secret,placeholder=xxxxxxxx,description=Your Azure Client Secret."`
ClientSecret string `json:"ClientSecret,omitempty" happydomain:"label=Client Secret,placeholder=xxxxxxxx,secret,description=Your Azure Client Secret."`
}
func (s *AzureDnsAPI) DNSControlName() string {

View file

@ -33,7 +33,7 @@ type AzurePrivateDnsAPI struct {
ResourceGroup string `json:"ResourceGroup,omitempty" happydomain:"label=Resource Group,placeholder=xxxxxxxx,required,description=Your Azure Resource Group."`
TenantID string `json:"TenantID,omitempty" happydomain:"label=Tenant ID,placeholder=xxxxxxxx,description=Your Azure Tenant ID."`
ClientID string `json:"ClientID,omitempty" happydomain:"label=Client ID,placeholder=xxxxxxxx,description=Your Azure Client ID."`
ClientSecret string `json:"ClientSecret,omitempty" happydomain:"label=Client Secret,placeholder=xxxxxxxx,description=Your Azure Client Secret."`
ClientSecret string `json:"ClientSecret,omitempty" happydomain:"label=Client Secret,placeholder=xxxxxxxx,secret,description=Your Azure Client Secret."`
}
func (s *AzurePrivateDnsAPI) DNSControlName() string {

View file

@ -29,9 +29,9 @@ import (
)
type ClouDNSAPI struct {
AuthID string `json:"AuthID,omitempty" happydomain:"label=Auth ID,placeholder=xxxxxxxx,required,description=Your ClouDNS auth ID"`
SubAuthID string `json:"SubAuthID,omitempty" happydomain:"label=Sub Auth ID,placeholder=xxxxxxxx,description=Your ClouDNS subauth token"`
Password string `json:"Password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,description=Your ClouDNS API password token"`
AuthID string `json:"AuthID,omitempty" happydomain:"label=Auth ID,placeholder=xxxxxxxx,required,secret,description=Your ClouDNS auth ID"`
SubAuthID string `json:"SubAuthID,omitempty" happydomain:"label=Sub Auth ID,placeholder=xxxxxxxx,secret,description=Your ClouDNS subauth token"`
Password string `json:"Password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,secret,description=Your ClouDNS API password token"`
}
func (s *ClouDNSAPI) DNSControlName() string {

View file

@ -29,8 +29,8 @@ import (
)
type CscGlobalAPI struct {
ApiKey string `json:"ApiKey,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,description=Your API key"`
UserToken string `json:"UserToken,omitempty" happydomain:"label=User token,placeholder=xxxxxxxx,required,description=Your user token"`
ApiKey string `json:"ApiKey,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,secret,description=Your API key"`
UserToken string `json:"UserToken,omitempty" happydomain:"label=User token,placeholder=xxxxxxxx,required,secret,description=Your user token"`
NotificationEmails string `json:"NotificationEmails,omitempty" happydomain:"label=Notification emails,placeholder=xxxxxxxx,description=Optional comma-separated list of email addresses to send notifications to"`
}

View file

@ -29,7 +29,7 @@ import (
)
type DNSimpleAPI struct {
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=xxxxxxxxxx,required,description=Provide a DNSimple account access token."`
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=xxxxxxxxxx,required,secret,description=Provide a DNSimple account access token."`
}
func (s *DNSimpleAPI) DNSControlName() string {

View file

@ -29,8 +29,8 @@ import (
)
type DNSMadeEasyAPI struct {
ApiKey string `json:"api_key,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx,required,description=API Key to retrieve from your account: See https://api-docs.dnsmadeeasy.com/."`
SecretKey string `json:"secret_key,omitempty" happydomain:"label=Secret Key,placeholder=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx,required,description=Secret key that comes with your API Key."`
ApiKey string `json:"api_key,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx,required,secret,description=API Key to retrieve from your account: See https://api-docs.dnsmadeeasy.com/."`
SecretKey string `json:"secret_key,omitempty" happydomain:"label=Secret Key,placeholder=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx,required,secret,description=Secret key that comes with your API Key."`
}
func (s *DNSMadeEasyAPI) DNSControlName() string {

View file

@ -29,8 +29,8 @@ import (
)
type DomainnameshopAPI struct {
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=your-domainnameshop-token,required,description=Domainnameshop API Token."`
Secret string `json:"secret,omitempty" happydomain:"label=Secret,placeholder=your-domainnameshop-secret,required,description=Domainnameshop API Secret."`
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=your-domainnameshop-token,required,secret,description=Domainnameshop API Token."`
Secret string `json:"secret,omitempty" happydomain:"label=Secret,placeholder=your-domainnameshop-secret,required,secret,description=Domainnameshop API Secret."`
}
func (s *DomainnameshopAPI) DNSControlName() string {

View file

@ -29,8 +29,8 @@ import (
)
type ExoscaleAPI struct {
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx,required,description=Your API key."`
SecretKey string `json:"secretkey,omitempty" happydomain:"label=Secret Key,placeholder=xxxxxxxx,required,description=Your secret key."`
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx,required,secret,description=Your API key."`
SecretKey string `json:"secretkey,omitempty" happydomain:"label=Secret Key,placeholder=xxxxxxxx,required,secret,description=Your secret key."`
DnsEndpoint string `json:"dns_endpoint,omitempty" happydomain:"label=DNS endpoint,placeholder=xxxxxxxx,description=DNS endpointy."`
}

View file

@ -29,7 +29,7 @@ import (
)
type GcoreAPI struct {
ApiKey string `json:"api_key,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,description=Your GCORE API Token."`
ApiKey string `json:"api_key,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,secret,description=Your GCORE API Token."`
}
func (s *GcoreAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type HEDNSAPI struct {
Username string `json:"username,omitempty" happydomain:"label=Username,placeholder=xxxxxxxx,required,description=The username you usually use to log on HE services."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,description=The password associated with you HE account."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,secret,description=The password associated with you HE account."`
TOTP string `json:"totp,omitempty" happydomain:"label=TOTP Key,placeholder=xxxxxxxx,description=If you enabled two factor authentication, you need to paste here your TOTP key."`
}

View file

@ -29,7 +29,7 @@ import (
)
type HostingdeAPI struct {
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=your-api-key,required,description=Provide your Hosting.de account access token."`
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=your-api-key,required,secret,description=Provide your Hosting.de account access token."`
OwnerAccountId string `json:"ownerAccountId,omitempty" happydomain:"label=Owner Account,placeholder=xxxxxxxxx,description=Identifier of the account owner."`
}

View file

@ -29,8 +29,8 @@ import (
)
type InternetbsAPI struct {
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx,required,description=Your API key."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,description=Your account password."`
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx,required,secret,description=Your API key."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,secret,description=Your account password."`
}
func (s *InternetbsAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type INWXAPI struct {
Username string `json:"username,omitempty" happydomain:"label=Username,placeholder=xxxxxxxx,required,description=The username you usually use to log on INWX services."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,description=The password associated with you INWX account."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,secret,description=The password associated with you INWX account."`
}
func (s *INWXAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type LoopiaAPI struct {
Username string `json:"username,omitempty" happydomain:"label=Username,placeholder=username@loopiaapi,required,description=Your Loopia API user name."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,description=Your Loopia API password."`
Password string `json:"password,omitempty" happydomain:"label=Password,placeholder=xxxxxxxx,required,secret,description=Your Loopia API password."`
}
func (s *LoopiaAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type LuaDnsAPI struct {
Email string `json:"email,omitempty" happydomain:"label=E-mail,placeholder=xxxxxxxx,required,description=Your email."`
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx,required,description=Your API key."`
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxx,required,secret,description=Your API key."`
}
func (s *LuaDnsAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type MythicBeastsAPI struct {
KeyID string `json:"keyID,omitempty" happydomain:"label=API key ID,placeholder=xxxxxxxx,required,description=Your API key ID."`
Secret string `json:"secret,omitempty" happydomain:"label=Secret,placeholder=xxxxxxxx,required,description=Your API secret."`
Secret string `json:"secret,omitempty" happydomain:"label=Secret,placeholder=xxxxxxxx,required,secret,description=Your API secret."`
}
func (s *MythicBeastsAPI) DNSControlName() string {

View file

@ -29,7 +29,7 @@ import (
)
type NamedotcomAPI struct {
APIKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=yourApiKeyFromNamedotcom,required"`
APIKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=yourApiKeyFromNamedotcom,required,secret"`
APIUser string `json:"apiuser,omitempty" happydomain:"label=API User,placeholder=yourUsername,required"`
}

View file

@ -30,7 +30,7 @@ import (
type NetcupAPI struct {
ApiKey string `json:"api_key,omitempty" happydomain:"label=API key,placeholder=your-api-key,required,description=Netcup API key."`
ApiPassword string `json:"api_password,omitempty" happydomain:"label=Password,placeholder=api-password,required,description=Netcup API password."`
ApiPassword string `json:"api_password,omitempty" happydomain:"label=Password,placeholder=api-password,required,secret,description=Netcup API password."`
CustomerNumber string `json:"customer_number,omitempty" happydomain:"label=Customer number,placeholder=123456,required,description=Netcup customer number."`
}

View file

@ -29,7 +29,7 @@ import (
)
type NetlifyAPI struct {
Token string `json:"token,omitempty" happydomain:"label=Netlify Access Token,placeholder=xxxxxxxxxx,required,description=Get your token on https://app.netlify.com/user/applications#personal-access-tokens."`
Token string `json:"token,omitempty" happydomain:"label=Netlify Access Token,placeholder=xxxxxxxxxx,required,secret,description=Get your token on https://app.netlify.com/user/applications#personal-access-tokens."`
Slug string `json:"slug,omitempty" happydomain:"label=Account Slug,description=Optional account slug (help us to understand how it is used)."`
}

View file

@ -29,7 +29,7 @@ import (
)
type OpensrsAPI struct {
ApiKey string `json:"apiKey,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,description=Your API key."`
ApiKey string `json:"apiKey,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,secret,description=Your API key."`
Username string `json:"username,omitempty" happydomain:"label=Username,placeholder=xxxxxxxx,required,description=Your username."`
BaseUrl string `json:"base_url,omitempty" happydomain:"label=Base URL,placeholder=xxxxxxxx,description=Alternate base URL."`
}

View file

@ -31,7 +31,7 @@ import (
type OracleAPI struct {
Compartment string `json:"compartment,omitempty" happydomain:"label=Compartment,placeholder=ORACLE_COMPARTMENT,description=Compartment."`
Fingerprint string `json:"fingerprint,omitempty" happydomain:"label=Fingerprint,placeholder=ORACLE_FINGERPRINT,required,description=Fingerprint."`
PrivateKey string `json:"private_key,omitempty" happydomain:"label=Private hey,placeholder=ORACLE_PRIVATE_KEY,required,description=Private key."`
PrivateKey string `json:"private_key,omitempty" happydomain:"label=Private key,placeholder=ORACLE_PRIVATE_KEY,required,secret,description=Private key."`
Region string `json:"region,omitempty" happydomain:"label=Region,placeholder=ORACLE_REGION,required,description=Region."`
TenancyOcid string `json:"tenancy_ocid,omitempty" happydomain:"label=Tenancy OCID,placeholder=ORACLE_TENANCY_OCID,required,description=Tenancy OCID."`
UserOcid string `json:"user_ocid,omitempty" happydomain:"label=User OCID,placeholder=ORACLE_USER_OCID,required,description=User OCID."`

View file

@ -29,7 +29,7 @@ import (
)
type PacketframeAPI struct {
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=xxxxxxxx,required,description=Your Packetframe Token."`
Token string `json:"token,omitempty" happydomain:"label=Token,placeholder=xxxxxxxx,required,secret,description=Your Packetframe Token."`
}
func (s *PacketframeAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type PorkbunAPI struct {
APIKey string `json:"api_key,omitempty" happydomain:"label=API Key,placeholder=xxxxxxxxxx,required,description=Get your API key on https://porkbun.com/account/api."`
SecretKey string `json:"secret_key,omitempty" happydomain:"label=Secret Key,placeholder=xxxxxxxxxx,required,description=Write the secret key corresponding to your API key."`
SecretKey string `json:"secret_key,omitempty" happydomain:"label=Secret Key,placeholder=xxxxxxxxxx,required,secret,description=Write the secret key corresponding to your API key."`
}
func (s *PorkbunAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type PowerdnsAPI struct {
ApiUrl string `json:"apiurl,omitempty" happydomain:"label=API Server Endpoint,placeholder=http://12.34.56.78"`
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,placeholder=a0b1c2d3e4f5=="`
ApiKey string `json:"apikey,omitempty" happydomain:"label=API Key,secret,placeholder=a0b1c2d3e4f5=="`
ServerID string `json:"server_id,omitempty" happydomain:"label=Server ID,placeholder=localhost,description=Unless you are using a specially configured reverse proxy leave blank"`
Certificate string `json:"certificate,omitempty" happydomain:"label=Certificate,placeholder=-----BEGIN CERTIFICATE-----,description=If you use a self-signed certificate paste it here,textarea"`
SkipTLSVerify bool `json:"skip_tls_verify,omitempty" happydomain:"label=Skip TLS Verify,description=Don't check the validity of the presented certificate (THIS IS INSECURE)"`

View file

@ -29,7 +29,7 @@ import (
)
type RwthAPI struct {
ApiKey string `json:"api_key,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,description=Your RWTH API Token."`
ApiKey string `json:"api_key,omitempty" happydomain:"label=API key,placeholder=xxxxxxxx,required,secret,description=Your RWTH API Token."`
}
func (s *RwthAPI) DNSControlName() string {

View file

@ -30,7 +30,7 @@ import (
type SakuraCloudAPI struct {
AccessToken string `json:"access_token,omitempty" happydomain:"label=Access Token,placeholder=xxxxxxxx,required,description=Your access token"`
AccessTokenSecret string `json:"access_token_secret,omitempty" happydomain:"label=Access Token Secret,placeholder=xxxxxxxx,required,description=Your secret"`
AccessTokenSecret string `json:"access_token_secret,omitempty" happydomain:"label=Access Token Secret,placeholder=xxxxxxxx,required,secret,description=Your secret"`
Endpoint string `json:"endpoint,omitempty" happydomain:"label=Endpoint,placeholder=https://secure.sakura.ad.jp/cloud/zone/is1a/api/cloud/1.1,description=Any zone endpoint (as DNS service is independent of zone)"`
}

View file

@ -30,7 +30,7 @@ import (
type SoftLayerAPI struct {
Username string `json:"username,omitempty" happydomain:"label=Username,placeholder=yourUsername,required"`
APIKey string `json:"api_key,omitempty" happydomain:"label=API Key,placeholder=yourApiKeyFromSoftLayer,required"`
APIKey string `json:"api_key,omitempty" happydomain:"label=API Key,placeholder=yourApiKeyFromSoftLayer,required,secret"`
}
func (s *SoftLayerAPI) DNSControlName() string {

View file

@ -30,8 +30,8 @@ import (
type TransIpAPI struct {
AccountName string `json:"account_name,omitempty" happydomain:"label=Account name,placeholder=xxxxxxxx,description=Your account name."`
PrivateKey string `json:"private_key,omitempty" happydomain:"label=Private key,placeholder=xxxxxxxx,description=Your account private key."`
AccessToken string `json:"access_token,omitempty" happydomain:"label=Access token,placeholder=xxxxxxxx,description=Your access roken."`
PrivateKey string `json:"private_key,omitempty" happydomain:"label=Private key,placeholder=xxxxxxxx,secret,description=Your account private key."`
AccessToken string `json:"access_token,omitempty" happydomain:"label=Access token,placeholder=xxxxxxxx,secret,description=Your access roken."`
}
func (s *TransIpAPI) DNSControlName() string {

642
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -134,11 +134,12 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi
router.GET("/domains/*_", serveIndex)
router.GET("/email-validation", serveIndex)
router.GET("/forgotten-password", serveIndex)
router.GET("/join", serveIndex)
router.GET("/generator/*_", serveIndex)
router.GET("/login", serveIndex)
router.GET("/me", serveIndex)
router.GET("/onboarding/*_", serveIndex)
router.GET("/providers/*_", serveIndex)
router.GET("/register", serveIndex)
router.GET("/services/*_", serveIndex)
router.GET("/tools/*_", serveIndex)
router.GET("/resolver/*_", serveIndex)

View file

@ -23,7 +23,9 @@ import {
getServiceSpecs,
getServiceSpecsByServiceType,
postServiceSpecsByServiceTypeInit,
postServiceSpecsByServiceTypeRecords,
} from "$lib/api-base/sdk.gen";
import type { dnsRR } from "$lib/dns_rr";
import type { ServiceInfos, ServiceSpec } from "$lib/model/service_specs.svelte";
import { unwrapSdkResponse } from "./errors";
@ -51,3 +53,15 @@ export async function initializeService(ssid: string): Promise<any> {
}),
);
}
export async function generateServiceRecords(ssid: string, value: any, domain?: string): Promise<dnsRR[]> {
const query: Record<string, string> = {};
if (domain) query.domain = domain;
return unwrapSdkResponse(
await postServiceSpecsByServiceTypeRecords({
path: { serviceType: ssid },
body: value,
query: query as any,
})
) as dnsRR[];
}

View file

@ -37,8 +37,8 @@ import type { UserSettings } from "$lib/model/usersettings";
import type { User, SignUpForm, LoginForm } from "$lib/model/user";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
export async function registerUser(form: SignUpForm): Promise<User> {
return unwrapSdkResponse(await postUsers({ body: form })) as unknown as User;
export async function registerUser(form: SignUpForm): Promise<boolean> {
return unwrapEmptyResponse(await postUsers({ body: form }));
}
export async function authUser(form: LoginForm): Promise<User> {

View file

@ -158,9 +158,9 @@
{#if !$appConfig.disable_registration}
<Button
class="d-none d-md-inline-block mx-1"
outline={!page.route || page.route.id != "/join"}
outline={!page.route || page.route.id != "/register"}
color="dark"
href="/join"
href="/register"
>
<Icon name="person-plus-fill" aria-hidden="true" />
{$t("menu.signup")}

View file

@ -24,7 +24,14 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { Input, InputGroup, InputGroupText, type InputType } from "@sveltestrap/sveltestrap";
import {
Button,
Icon,
Input,
InputGroup,
InputGroupText,
type InputType,
} from "@sveltestrap/sveltestrap";
import type { Field } from "$lib/model/custom_form.svelte";
import { t } from "$lib/translations";
@ -54,6 +61,8 @@
specs.type === "time.Duration" || specs.type === "common.Duration" ? "s" : null,
);
let secretVisible = $state(false);
let inputtype: InputType = $derived(
specs.type && (specs.type.startsWith("uint") || specs.type.startsWith("int"))
? "number"
@ -61,7 +70,9 @@
? "checkbox"
: specs.textarea
? "textarea"
: "text",
: specs.secret && !secretVisible
? "password"
: "text",
);
let inputmax: number | undefined = $derived(computeInputmax(specs));
@ -218,4 +229,17 @@
{#if unit !== null}
<InputGroupText>{unit}</InputGroupText>
{/if}
{#if specs.secret}
<Button
color="secondary"
outline
size="sm"
type="button"
onclick={() => (secretVisible = !secretVisible)}
title={secretVisible ? "Hide" : "Show"}
>
<Icon name={secretVisible ? "eye-slash" : "eye"} />
</Button>
{/if}
</InputGroup>

View file

@ -552,6 +552,29 @@
"ttl": "Remaining time in cache",
"showDNSSEC": "Show DNSSEC records in answer (if any)"
},
"generator": {
"title": "DNS Record Generators",
"description": "Free online DNS record generators: CAA, DMARC, SPF, MX, DKIM, SRV, and more. Build and preview DNS zone records instantly.",
"subtitle": "Pick a record type to build and preview its DNS zone entry — no account needed.",
"icon-alt": "{{name}} icon",
"svctype": {
"title": "{{name}} Generator",
"page-title": "DNS Record Generator",
"description": "Free online {{name}} DNS record generator. Configure and preview your {{name}} record in zone file format.",
"not-found": "Service type <code>{{svctype}}</code> not found.",
"browse-all": "Browse all generators",
"domain-settings": "Your Domain Name",
"domain-help": "Enter the domain name where the record will be created. The generated records will use this domain.",
"configure-record": "Fill the Required Information",
"generated-records": "Get the Generated DNS Records",
"fill-form": "Fill in the form above to generate records.",
"generating": "Generating…",
"no-records": "No records generated.",
"cta-title": "Handle DNS complexity with happyDomain",
"cta-text": "happyDomain lets you manage all your DNS records through a visual interface — no zone file editing required.",
"cta-button": "Create a free account"
}
},
"zones": {
"viewer": "Zone Viewer",
"viewer-subtitle": "Your services at a glance",

View file

@ -27,6 +27,10 @@ import { getAvailableResourceTypes, type ProviderInfos } from "$lib/model/provid
import type { ServiceCombined } from "$lib/model/service.svelte";
import { servicesSpecs, servicesSpecsLoaded } from "$lib/stores/services";
export const SERVICE_FAMILY_ABSTRACT = "abstract";
export const SERVICE_FAMILY_HIDDEN = "hidden";
export const SERVICE_FAMILY_PROVIDER = "provider";
export class ServiceRestrictions {
alone = $state(false);
exclusive = $state<Array<string>>([]);

View file

@ -39,6 +39,7 @@ interface Params {
min?: number;
max?: number;
suggestion?: string;
svctype?: string;
// add more parameters that are used here
}

View file

@ -99,7 +99,7 @@ export const load: Load = async ({ route, url }) => {
locale.set(user.settings.language);
}
} catch (err) {
if (route.id != null && route.id != "/login" && route.id != "/forgotten-password" && route.id != "/join" && !route.id.startsWith("/resolver") && route.id != "/providers/features" && !route.id.startsWith("/email-validation")) {
if (route.id != null && route.id != "/login" && route.id != "/forgotten-password" && route.id != "/register" && !route.id.startsWith("/resolver") && !route.id.startsWith("/generator") && route.id != "/providers/features" && !route.id.startsWith("/email-validation")) {
toasts.addToast({
type: 'error',
title: get(t)("errors.session.title"),

View file

@ -26,5 +26,5 @@ import { appConfig } from "$lib/stores/config";
export const load: Load = async () => {
if (get(appConfig).disable_registration) redirect(302, "/login");
else redirect(302, "/join");
else redirect(302, "/register");
};

View file

@ -26,5 +26,5 @@ import { appConfig } from "$lib/stores/config";
export const load: Load = async () => {
if (get(appConfig).disable_registration) redirect(302, "/login");
else redirect(302, "/join");
else redirect(302, "/register");
};

View file

@ -0,0 +1,124 @@
<!--
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 { Col, Container, Row, Spinner } from "@sveltestrap/sveltestrap";
import PageTitle from "$lib/components/PageTitle.svelte";
import { SERVICE_FAMILY_HIDDEN } from "$lib/model/service_specs.svelte";
import { refreshServicesSpecs, servicesSpecs, servicesSpecsLoaded } from "$lib/stores/services";
import type { ServiceInfos } from "$lib/model/service_specs.svelte";
import { t } from "$lib/translations";
onMount(() => {
if (!$servicesSpecsLoaded) {
refreshServicesSpecs();
}
});
function groupByCategory(specs: Record<string, ServiceInfos>): Record<string, ServiceInfos[]> {
const groups: Record<string, ServiceInfos[]> = {};
for (const svc of Object.values(specs)) {
if (svc.family === SERVICE_FAMILY_HIDDEN) continue;
const cats = svc.categories && svc.categories.length > 0 ? svc.categories : ["general"];
for (const cat of cats) {
if (!groups[cat]) groups[cat] = [];
groups[cat].push(svc);
}
}
return groups;
}
let grouped = $derived(groupByCategory($servicesSpecs));
let categoryNames = $derived(Object.keys(grouped).sort());
</script>
<svelte:head>
<title>{$t("generator.title")} - happyDomain</title>
<meta
name="description"
content={$t("generator.description")}
/>
</svelte:head>
<div class="my-5 container flex-fill">
<PageTitle
title={$t("generator.title")}
subtitle={$t("generator.subtitle")}
/>
{#if !$servicesSpecsLoaded}
<div class="d-flex justify-content-center mt-5">
<Spinner />
</div>
{:else}
{#each categoryNames as category}
<section class="mb-5">
<h2 class="h4 text-capitalize mb-3">{category}</h2>
<Row cols={{ xs: 1, sm: 2, md: 3, lg: 4 }}>
{#each grouped[category] as svc (svc._svctype)}
<Col class="mb-3">
<a
href="/generator/{encodeURIComponent(svc._svctype)}"
class="card h-100 text-decoration-none text-reset generator-card"
>
<div class="card-body d-flex align-items-start gap-3">
{#if svc._svcicon}
<img
src="/api/service_specs/{encodeURIComponent(svc._svctype)}/icon.png"
alt={$t("generator.icon-alt", { name: svc.name })}
width="32"
height="32"
class="flex-shrink-0"
style="object-fit: contain;"
/>
{:else}
<span class="flex-shrink-0 fs-4">📄</span>
{/if}
<div>
<h3 class="h6 card-title mb-1">{svc.name}</h3>
{#if svc.description}
<p class="card-text small text-muted mb-0">{svc.description}</p>
{/if}
</div>
</div>
</a>
</Col>
{/each}
</Row>
</section>
{/each}
{/if}
</div>
<style>
.generator-card {
border: 1px solid rgba(0, 0, 0, 0.1);
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.generator-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border-color: var(--bs-primary);
}
</style>

View file

@ -0,0 +1,210 @@
<!--
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 { Col, Container, Input, Row, Spinner } from "@sveltestrap/sveltestrap";
import {
generateServiceRecords,
initializeService,
listServiceSpecs,
} from "$lib/api/service_specs";
import PageTitle from "$lib/components/PageTitle.svelte";
import ServiceEditor from "$lib/components/services/ServiceEditor.svelte";
import type { Domain } from "$lib/model/domain";
import type { ServiceInfos } from "$lib/model/service_specs.svelte";
import { t } from "$lib/translations";
import { printRR } from "$lib/dns";
import type { dnsRR } from "$lib/dns_rr";
interface Props {
data: { svctype: string };
}
let { data }: Props = $props();
let svctype = $derived(data.svctype);
let dataPromise = $derived(Promise.all([listServiceSpecs(), initializeService(svctype)]));
let spec: ServiceInfos | null = $state(null);
$effect(() => {
dataPromise
.then(([specs, _iv]) => {
spec = specs[svctype] ?? null;
})
.catch(() => {
spec = null;
});
});
let domain: string = $state("");
let serviceValue: any = $state({});
$effect(() => {
dataPromise
.then(([_, iv]) => {
serviceValue = iv ?? {};
})
.catch(() => {
serviceValue = {};
});
});
let recordsPromise: Promise<dnsRR[]> | null = $state(null);
let generateDebounce: ReturnType<typeof setTimeout>;
$effect(() => {
JSON.stringify(serviceValue); // track all changes
domain; // track domain changes
clearTimeout(generateDebounce);
generateDebounce = setTimeout(() => {
recordsPromise = generateServiceRecords(svctype, serviceValue, (domain && !domain.endsWith(".") ? domain + "." : domain) || undefined);
}, 400);
});
let mockDomain: Domain = $derived({
id: "preview",
id_provider: "preview",
domain: domain || "example.com.",
id_owner: "preview",
group: "",
zone_history: [],
});
</script>
<svelte:head>
{#if spec}
<title>{$t("generator.svctype.title", { name: spec.name })} - happyDomain</title>
<meta
name="description"
content={$t("generator.svctype.description", { name: spec.name })}
/>
{:else}
<title>{$t("generator.svctype.page-title")} - happyDomain</title>
{/if}
</svelte:head>
{#await dataPromise}
<div class="d-flex justify-content-center mt-5">
<Spinner />
</div>
{:then [specs, _iv]}
{#if !specs[svctype]}
<div class="my-5 container flex-fill">
<div class="alert alert-warning">
{@html $t("generator.svctype.not-found", { svctype })}
<a href="/generator">{$t("generator.svctype.browse-all")}</a>
</div>
</div>
{:else}
{@const svcSpec = specs[svctype]}
<Container fluid class="my-4 flex-fill">
<Row class="justify-content-center">
<Col lg="8" xl="7">
<PageTitle
title={$t("generator.svctype.title", { name: svcSpec.name })}
subtitle={svcSpec.description}
/>
<div class="card mb-4">
<h4 class="card-header fw-semibold">
1. {$t("generator.svctype.domain-settings")}
</h4>
<div class="card-body">
<p class="text-muted small mb-2">
{$t("generator.svctype.domain-help")}
</p>
<Input type="text" autofocus placeholder="example.com." bind:value={domain} />
</div>
</div>
<div class="card mb-4">
<h4 class="card-header fw-semibold">
2. {$t("generator.svctype.configure-record")}
</h4>
<div class="card-body">
{#key svctype}
<ServiceEditor
dn=""
origin={mockDomain}
type={svctype}
bind:value={serviceValue}
/>
{/key}
</div>
</div>
<div class="card mb-4">
<h4 class="card-header fw-semibold">
3. {$t("generator.svctype.generated-records")}
</h4>
<div class="card-body p-0">
{#if recordsPromise === null}
<div class="p-3 text-muted small">
{$t("generator.svctype.fill-form")}
</div>
{:else}
{#await recordsPromise}
<div class="p-3 d-flex align-items-center gap-2 text-muted">
<Spinner size="sm" />
<span>{$t("generator.svctype.generating")}</span>
</div>
{:then records}
{#if records && records.length > 0}
<pre class="mb-0 p-3 font-monospace small">{records.map((rr) => printRR(rr)).join(
"\n",
)}</pre>
{:else}
<div class="p-3 text-muted small">
{$t("generator.svctype.no-records")}
</div>
{/if}
{:catch err}
<div class="p-3 text-danger small">{err.message}</div>
{/await}
{/if}
</div>
</div>
<div class="card border-primary mb-4">
<div class="card-body">
<h5 class="card-title">{$t("generator.svctype.cta-title")}</h5>
<p class="card-text text-muted">
{$t("generator.svctype.cta-text")}
</p>
<a href="/register" class="btn btn-primary">{$t("generator.svctype.cta-button")}</a>
</div>
</div>
</Col>
</Row>
</Container>
{/if}
{:catch err}
<div class="my-5 container flex-fill">
<div class="alert alert-danger">{err.message}</div>
</div>
{/await}

View file

@ -0,0 +1,26 @@
// 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 type { Load } from "@sveltejs/kit";
export const load: Load = async ({ params }) => {
return { svctype: (params as Record<string, string>).svctype };
};

View file

@ -52,7 +52,7 @@
{#if !$appConfig.disable_registration}
<div class="text-center mt-4">
{$t("account.ask-have")}
<a href="/join" class="fw-bold">
<a href="/register" class="fw-bold">
{$t("account.join")}
</a>
</div>