Skip to content

Commit 613bfa2

Browse files
committed
fix(config): support legacy users config
1 parent f7a2ad1 commit 613bfa2

4 files changed

Lines changed: 411 additions & 3 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package loaders
2+
3+
// ExpandLegacyUsersEnvForTest exposes expandLegacyUsersEnv for package-external tests.
4+
var ExpandLegacyUsersEnvForTest = expandLegacyUsersEnv

internal/utils/loaders/loader_env.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package loaders
33
import (
44
"fmt"
55
"os"
6+
"strings"
67

78
"github.com/steveiliop56/tinyauth/internal/config"
89

@@ -13,7 +14,9 @@ import (
1314
type EnvLoader struct{}
1415

1516
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
16-
vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)
17+
environ := expandLegacyUsersEnv(os.Environ())
18+
19+
vars := env.FindPrefixedEnvVars(environ, config.DefaultNamePrefix, cmd.Configuration)
1720
if len(vars) == 0 {
1821
return false, nil
1922
}
@@ -24,3 +27,72 @@ func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
2427

2528
return true, nil
2629
}
30+
31+
// expandLegacyUsersEnv detects the legacy flat TINYAUTH_AUTH_USERS=user:hash,user2:hash2 format
32+
// and expands it into per-user entries that paerser can decode into map[string]UserConfig:
33+
//
34+
// TINYAUTH_AUTH_USERS_username_PASSWORD=hash
35+
// TINYAUTH_AUTH_USERS_username_TOTPSECRET=secret (when present)
36+
//
37+
// If TINYAUTH_AUTH_USERS is already in the new map form (i.e. other
38+
// TINYAUTH_AUTH_USERS_* keys are present), it is left untouched.
39+
func expandLegacyUsersEnv(environ []string) []string {
40+
const legacyKey = "TINYAUTH_AUTH_USERS"
41+
const mapPrefix = legacyKey + "_"
42+
43+
var legacyValue string
44+
hasMapEntries := false
45+
46+
for _, e := range environ {
47+
k, v, _ := strings.Cut(e, "=")
48+
ku := strings.ToUpper(k)
49+
if ku == legacyKey {
50+
legacyValue = v
51+
} else if strings.HasPrefix(ku, mapPrefix) {
52+
hasMapEntries = true
53+
}
54+
}
55+
56+
// Nothing to do: either no legacy var, or already using the new map form.
57+
if legacyValue == "" || hasMapEntries {
58+
return environ
59+
}
60+
61+
// Filter out the legacy key and replace with expanded entries.
62+
expanded := make([]string, 0, len(environ))
63+
for _, e := range environ {
64+
k, _, _ := strings.Cut(e, "=")
65+
if strings.ToUpper(k) == legacyKey {
66+
continue
67+
}
68+
expanded = append(expanded, e)
69+
}
70+
71+
for _, entry := range strings.Split(legacyValue, ",") {
72+
entry = strings.TrimSpace(entry)
73+
if entry == "" {
74+
continue
75+
}
76+
if strings.Contains(entry, "$$") {
77+
entry = strings.ReplaceAll(entry, "$$", "$")
78+
}
79+
parts := strings.SplitN(entry, ":", 4)
80+
if len(parts) < 2 || len(parts) > 3 {
81+
continue
82+
}
83+
username := strings.TrimSpace(parts[0])
84+
password := strings.TrimSpace(parts[1])
85+
if username == "" || password == "" {
86+
continue
87+
}
88+
expanded = append(expanded, mapPrefix+strings.ToUpper(username)+"_PASSWORD="+password)
89+
if len(parts) == 3 {
90+
totp := strings.TrimSpace(parts[2])
91+
if totp != "" {
92+
expanded = append(expanded, mapPrefix+strings.ToUpper(username)+"_TOTPSECRET="+totp)
93+
}
94+
}
95+
}
96+
97+
return expanded
98+
}

internal/utils/loaders/loader_file.go

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package loaders
22

33
import (
44
"os"
5+
"path/filepath"
6+
"strings"
57

68
"github.com/rs/zerolog/log"
79
"github.com/traefik/paerser/cli"
810
"github.com/traefik/paerser/file"
911
"github.com/traefik/paerser/flag"
12+
"gopkg.in/yaml.v3"
1013
)
1114

1215
type FileLoader struct{}
@@ -32,11 +35,129 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
3235

3336
log.Warn().Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
3437

35-
err = file.Decode(flags[configFileFlag], cmd.Configuration)
38+
filePath := flags[configFileFlag]
3639

37-
if err != nil {
40+
ext := strings.ToLower(filepath.Ext(filePath))
41+
if ext == ".yml" || ext == ".yaml" {
42+
content, err := os.ReadFile(filepath.Clean(filePath))
43+
if err != nil {
44+
return false, err
45+
}
46+
47+
data := make(map[string]interface{})
48+
if err = yaml.Unmarshal(content, data); err != nil {
49+
return false, err
50+
}
51+
52+
normalizeUsersInRawMap(data)
53+
54+
// Re-marshal to YAML and use file.DecodeContent so we stay within the
55+
// existing public paerser API without modifying the submodule.
56+
normalised, err := yaml.Marshal(data)
57+
if err != nil {
58+
return false, err
59+
}
60+
61+
if err = file.DecodeContent(string(normalised), ext, cmd.Configuration); err != nil {
62+
return false, err
63+
}
64+
65+
return true, nil
66+
}
67+
68+
if err = file.Decode(filePath, cmd.Configuration); err != nil {
3869
return false, err
3970
}
4071

4172
return true, nil
4273
}
74+
75+
// normalizeUsersInRawMap converts the legacy users list format to the map format that paerser
76+
// expects for map[string]UserConfig. Both of these YAML shapes are accepted:
77+
//
78+
// # legacy: list of "username:hash" or "username:hash:totp" strings
79+
// auth:
80+
// users:
81+
// - alice:$2a$...
82+
// - bob:$2a$...:TOTPSECRET
83+
//
84+
// # legacy: single-key map per entry { alice: "$2a$..." }
85+
// auth:
86+
// users:
87+
// - alice: "$2a$..."
88+
//
89+
// # new map form (passed through unchanged)
90+
// auth:
91+
// users:
92+
// alice:
93+
// password: "$2a$..."
94+
func normalizeUsersInRawMap(data map[string]interface{}) {
95+
authRaw, ok := data["auth"]
96+
if !ok {
97+
return
98+
}
99+
100+
authMap, ok := authRaw.(map[string]interface{})
101+
if !ok {
102+
return
103+
}
104+
105+
usersRaw, ok := authMap["users"]
106+
if !ok {
107+
return
108+
}
109+
110+
// Already a map — the new structured form, leave it alone.
111+
if _, isMap := usersRaw.(map[string]interface{}); isMap {
112+
return
113+
}
114+
115+
slice, ok := usersRaw.([]interface{})
116+
if !ok {
117+
return
118+
}
119+
120+
normalized := make(map[string]interface{})
121+
122+
for _, entry := range slice {
123+
switch v := entry.(type) {
124+
case string:
125+
// "username:hash" or "username:hash:totp"
126+
if strings.Contains(v, "$$") {
127+
v = strings.ReplaceAll(v, "$$", "$")
128+
}
129+
parts := strings.SplitN(v, ":", 4)
130+
if len(parts) < 2 || len(parts) > 3 {
131+
continue
132+
}
133+
username := strings.TrimSpace(parts[0])
134+
password := strings.TrimSpace(parts[1])
135+
if username == "" || password == "" {
136+
continue
137+
}
138+
userMap := map[string]interface{}{"password": password}
139+
if len(parts) == 3 {
140+
totp := strings.TrimSpace(parts[2])
141+
if totp != "" {
142+
userMap["totpSecret"] = totp
143+
}
144+
}
145+
normalized[username] = userMap
146+
147+
case map[string]interface{}:
148+
// Single-key map: { "username": "hash" } or { "username": { "password": "hash", ... } }
149+
for username, val := range v {
150+
switch pw := val.(type) {
151+
case string:
152+
normalized[username] = map[string]interface{}{"password": pw}
153+
case map[string]interface{}:
154+
normalized[username] = pw
155+
}
156+
}
157+
}
158+
}
159+
160+
if len(normalized) > 0 {
161+
authMap["users"] = normalized
162+
}
163+
}

0 commit comments

Comments
 (0)