mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-21 12:11:27 +02:00
More WIP
This commit is contained in:
@@ -82,11 +82,10 @@ func initServices(
|
||||
|
||||
svc.customClaimService = service.NewCustomClaimService(db)
|
||||
svc.webauthnModule, err = webauthn.New(webauthn.Dependencies{
|
||||
DB: db,
|
||||
AppURL: common.EnvConfig.AppURL,
|
||||
Signer: svc.jwtService,
|
||||
AuditLog: svc.auditLogService,
|
||||
AppConfig: svc.appConfigService,
|
||||
DB: db,
|
||||
AppURL: common.EnvConfig.AppURL,
|
||||
Signer: svc.jwtService,
|
||||
AuditLog: svc.auditLogService,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WebAuthn module: %w", err)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type AppConfigVariable struct {
|
||||
Key string `gorm:"primaryKey;not null"`
|
||||
Value string
|
||||
}
|
||||
|
||||
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
|
||||
func (a *AppConfigVariable) IsTrue() bool {
|
||||
ok, _ := strconv.ParseBool(a.Value)
|
||||
return ok
|
||||
}
|
||||
|
||||
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
|
||||
func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
|
||||
val, err := strconv.Atoi(a.Value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(val) * time.Minute
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
// General
|
||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||
HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public
|
||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||
// Email
|
||||
RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public
|
||||
SmtpHost AppConfigVariable `key:"smtpHost"`
|
||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||
SmtpUser AppConfigVariable `key:"smtpUser"`
|
||||
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
|
||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
|
||||
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
|
||||
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
|
||||
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
|
||||
// LDAP
|
||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
|
||||
LdapBase AppConfigVariable `key:"ldapBase"`
|
||||
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
||||
LdapSkipCertVerify AppConfigVariable `key:"ldapSkipCertVerify"`
|
||||
LdapAttributeUserUniqueIdentifier AppConfigVariable `key:"ldapAttributeUserUniqueIdentifier"`
|
||||
LdapAttributeUserUsername AppConfigVariable `key:"ldapAttributeUserUsername"`
|
||||
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName AppConfigVariable `key:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
|
||||
}
|
||||
|
||||
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
|
||||
// Use reflection to iterate through all fields
|
||||
cfgValue := reflect.ValueOf(c).Elem()
|
||||
cfgType := cfgValue.Type()
|
||||
|
||||
res := make([]AppConfigVariable, 0, cfgType.NumField())
|
||||
|
||||
for i := range cfgType.NumField() {
|
||||
field := cfgType.Field(i)
|
||||
|
||||
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're only showing public variables and this is not public, skip it
|
||||
if !showAll && attrs != "public" {
|
||||
continue
|
||||
}
|
||||
|
||||
value := cfgValue.Field(i).FieldByName("Value").String()
|
||||
|
||||
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
|
||||
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" {
|
||||
value = "XXXXXXXXXX"
|
||||
}
|
||||
|
||||
appConfigVariable := AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
res = append(res, appConfigVariable)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (c *AppConfig) FieldByKey(key string) (defaultValue string, err error) {
|
||||
rv := reflect.ValueOf(c).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
// Find the field in the struct whose "key" tag matches
|
||||
for i := range rt.NumField() {
|
||||
// Grab only the first part of the key, if there's a comma with additional properties
|
||||
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
|
||||
keyFromTag := tagValue[0]
|
||||
if keyFromTag != key {
|
||||
continue
|
||||
}
|
||||
|
||||
valueField := rv.Field(i).FieldByName("Value")
|
||||
return valueField.String(), nil
|
||||
}
|
||||
|
||||
// If we are here, the config key was not found
|
||||
return "", AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
func (c *AppConfig) UpdateField(key string, value string) error {
|
||||
rv := reflect.ValueOf(c).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
// Find the field in the struct whose "key" tag matches, then update that
|
||||
for i := range rt.NumField() {
|
||||
// Separate the key (before the comma) from any optional attributes after
|
||||
tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
|
||||
if tagValue != key {
|
||||
continue
|
||||
}
|
||||
|
||||
valueField := rv.Field(i).FieldByName("Value")
|
||||
if !valueField.CanSet() {
|
||||
return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key)
|
||||
}
|
||||
|
||||
// Update the value
|
||||
valueField.SetString(value)
|
||||
|
||||
// Return once updated
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we're here, we have not found the right field to update
|
||||
return AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
type AppConfigKeyNotFoundError struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Error() string {
|
||||
return "cannot find config key '" + e.field + "'"
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
|
||||
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
|
||||
return ok
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// We use model_test here to avoid an import cycle
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
expected time.Duration
|
||||
expectedSeconds int
|
||||
}{
|
||||
{
|
||||
name: "valid positive integer",
|
||||
value: "60",
|
||||
expected: 60 * time.Minute,
|
||||
expectedSeconds: 3600,
|
||||
},
|
||||
{
|
||||
name: "valid zero integer",
|
||||
value: "0",
|
||||
expected: 0,
|
||||
expectedSeconds: 0,
|
||||
},
|
||||
{
|
||||
name: "negative integer",
|
||||
value: "-30",
|
||||
expected: -30 * time.Minute,
|
||||
expectedSeconds: -1800,
|
||||
},
|
||||
{
|
||||
name: "invalid non-integer",
|
||||
value: "not-a-number",
|
||||
expected: 0,
|
||||
expectedSeconds: 0,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
value: "",
|
||||
expected: 0,
|
||||
expectedSeconds: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configVar := model.AppConfigVariable{
|
||||
Value: tt.value,
|
||||
}
|
||||
|
||||
result := configVar.AsDurationMinutes()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
assert.Equal(t, tt.expectedSeconds, int(result.Seconds()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This test ensures that the model.AppConfig and dto.AppConfigUpdateDto structs match:
|
||||
// - They should have the same properties, where the "json" tag of dto.AppConfigUpdateDto should match the "key" tag in model.AppConfig
|
||||
// - dto.AppConfigDto should not include "internal" fields from model.AppConfig
|
||||
// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time
|
||||
func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
|
||||
appConfigType := reflect.TypeFor[model.AppConfig]()
|
||||
updateDtoType := reflect.TypeFor[dto.AppConfigUpdateDto]()
|
||||
|
||||
// Process AppConfig fields
|
||||
appConfigFields := make(map[string]string)
|
||||
for field := range appConfigType.Fields() {
|
||||
if field.Tag.Get("key") == "" {
|
||||
// Skip internal fields
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the key name from the tag (takes the part before any comma)
|
||||
keyTag := field.Tag.Get("key")
|
||||
keyName, _, _ := strings.Cut(keyTag, ",")
|
||||
|
||||
appConfigFields[field.Name] = keyName
|
||||
}
|
||||
|
||||
// Process AppConfigUpdateDto fields
|
||||
dtoFields := make(map[string]string)
|
||||
for field := range updateDtoType.Fields() {
|
||||
// Extract the json name from the tag (takes the part before any binding constraints)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
jsonName, _, _ := strings.Cut(jsonTag, ",")
|
||||
|
||||
dtoFields[jsonName] = field.Name
|
||||
}
|
||||
|
||||
// Verify every AppConfig field has a matching DTO field with the same name
|
||||
for fieldName, keyName := range appConfigFields {
|
||||
if strings.HasSuffix(fieldName, "ImageType") {
|
||||
// Skip internal fields that shouldn't be in the DTO
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if there's a DTO field with a matching JSON tag
|
||||
_, exists := dtoFields[keyName]
|
||||
assert.True(t, exists, "Field %s with key '%s' in AppConfig has no matching field in AppConfigUpdateDto", fieldName, keyName)
|
||||
}
|
||||
|
||||
// Verify every DTO field has a matching AppConfig field
|
||||
for jsonName, fieldName := range dtoFields {
|
||||
// Find a matching field in AppConfig by key tag
|
||||
found := false
|
||||
for _, keyName := range appConfigFields {
|
||||
if keyName == jsonName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, found, "Field %s with json tag '%s' in AppConfigUpdateDto has no matching field in AppConfig", fieldName, jsonName)
|
||||
}
|
||||
}
|
||||
@@ -696,7 +696,7 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for key, value := range ldapConfigs {
|
||||
configVar := model.AppConfigVariable{Key: key, Value: value}
|
||||
configVar := appconfig.AppConfigVariable{Key: key, Value: value}
|
||||
if err := tx.Create(&configVar).Error; err != nil {
|
||||
return fmt.Errorf("failed to create config variable '%s': %w", key, err)
|
||||
}
|
||||
@@ -708,7 +708,8 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to set LDAP test config: %w", err)
|
||||
}
|
||||
|
||||
if err := s.appConfigService.LoadDbConfig(ctx); err != nil {
|
||||
err = s.appConfigService.LoadDbConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load app config: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
||||
}
|
||||
|
||||
// getEmailer builds an emailer.Emailer from the current app config.
|
||||
func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConfig) (emailer.Emailer, error) {
|
||||
func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *appconfig.AppConfigModel) (emailer.Emailer, error) {
|
||||
// We support SMTP only (for now)
|
||||
connString, err := smtpConnString(dbConfig)
|
||||
if err != nil {
|
||||
@@ -117,8 +117,8 @@ func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConf
|
||||
|
||||
// smtpConnString builds the SMTP connection string that go-kit's emailer expects:
|
||||
// smtp://<username>:<password>@<host>:<port>?fromAddress=<address>&fromName=<name>&tls=<none|starttls|tls>&insecureSkipVerify=<true|false>
|
||||
func smtpConnString(dbConfig *model.AppConfig) (string, error) {
|
||||
host := dbConfig.SmtpHost.Value
|
||||
func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) {
|
||||
host := dbConfig.SmtpHost.String()
|
||||
if host == "" {
|
||||
return "", errors.New("SMTP host is not configured")
|
||||
}
|
||||
@@ -127,28 +127,28 @@ func smtpConnString(dbConfig *model.AppConfig) (string, error) {
|
||||
Scheme: "smtp",
|
||||
Host: host,
|
||||
}
|
||||
port := dbConfig.SmtpPort.Value
|
||||
port := dbConfig.SmtpPort.String()
|
||||
if port != "" {
|
||||
u.Host = net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// Include credentials when set
|
||||
smtpUser := dbConfig.SmtpUser.Value
|
||||
smtpPassword := dbConfig.SmtpPassword.Value
|
||||
smtpUser := dbConfig.SmtpUser.String()
|
||||
smtpPassword := dbConfig.SmtpPassword.String()
|
||||
if smtpUser != "" || smtpPassword != "" {
|
||||
u.User = url.UserPassword(smtpUser, smtpPassword)
|
||||
}
|
||||
|
||||
// TLS values from config: none, starttls, tls
|
||||
tlsMode := dbConfig.SmtpTls.Value
|
||||
tlsMode := dbConfig.SmtpTls.String()
|
||||
if tlsMode == "" {
|
||||
tlsMode = "none"
|
||||
}
|
||||
|
||||
// Build the query string args
|
||||
q := url.Values{}
|
||||
q.Set("fromAddress", dbConfig.SmtpFrom.Value)
|
||||
q.Set("fromName", dbConfig.AppName.Value)
|
||||
q.Set("fromAddress", dbConfig.SmtpFrom.String())
|
||||
q.Set("fromName", dbConfig.AppName.String())
|
||||
q.Set("tls", tlsMode)
|
||||
if dbConfig.SmtpSkipCertVerify.IsTrue() {
|
||||
q.Set("insecureSkipVerify", "true")
|
||||
|
||||
Reference in New Issue
Block a user