Compare commits
10 commits
666756f644
...
8a2a28e4be
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a2a28e4be | |||
| e341ea6beb | |||
| 69c9ba1d8d | |||
| 50ff2a1c7a | |||
| fece9cc4a5 | |||
| 9203e71494 | |||
| 36a7d8e9d3 | |||
| ae675d6451 | |||
| c850cfb0db | |||
| 07b5553369 |
80 changed files with 1940 additions and 653 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
36
internal/mailer/log.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
37
internal/usecase/domain_log/noop.go
Normal file
37
internal/usecase/domain_log/noop.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)."`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
642
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>>([]);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface Params {
|
|||
min?: number;
|
||||
max?: number;
|
||||
suggestion?: string;
|
||||
svctype?: string;
|
||||
// add more parameters that are used here
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
124
web/src/routes/generator/+page.svelte
Normal file
124
web/src/routes/generator/+page.svelte
Normal 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>
|
||||
210
web/src/routes/generator/[svctype]/+page.svelte
Normal file
210
web/src/routes/generator/[svctype]/+page.svelte
Normal 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}
|
||||
26
web/src/routes/generator/[svctype]/+page.ts
Normal file
26
web/src/routes/generator/[svctype]/+page.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue