Skip to content

Commit cf2cea0

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

4 files changed

Lines changed: 404 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: 116 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,122 @@ 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+
if err = file.DecodeFromRawMap(data, cmd.Configuration); err != nil {
55+
return false, err
56+
}
57+
58+
return true, nil
59+
}
60+
61+
if err = file.Decode(filePath, cmd.Configuration); err != nil {
3862
return false, err
3963
}
4064

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

0 commit comments

Comments
 (0)