v2.4.1 7c3b292

store.go RawHistoryFolder

1// Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files.
2package domainsecrets
3
4import (
5	"bufio"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10
11	"github.com/bnema/zerowrap"
12)
13
14// FileStore implements the DomainSecretStore interface using filesystem-based env files.
15// This adapter is responsible only for file I/O operations; domain validation
16// should be performed by the use case layer before calling these methods.
17type FileStore struct {
18	envDir string
19	log    zerowrap.Logger
20}
21
22// NewFileStore creates a new file-based domain secret store.
23func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
24	// Ensure env directory exists
25	if err := os.MkdirAll(envDir, 0700); err != nil {
26		return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
27	}
28
29	log.Debug().
30		Str(zerowrap.FieldLayer, "adapter").
31		Str(zerowrap.FieldAdapter, "domainsecrets").
32		Str("env_dir", envDir).
33		Msg("domain secret store initialized")
34
35	return &FileStore{
36		envDir: envDir,
37		log:    log,
38	}, nil
39}
40
41// ListKeys returns the list of secret keys for a domain (not values).
42func (s *FileStore) ListKeys(domain string) ([]string, error) {
43	envFile := s.getEnvFilePath(domain)
44
45	file, err := os.Open(envFile)
46	if err != nil {
47		if os.IsNotExist(err) {
48			return []string{}, nil
49		}
50		return nil, fmt.Errorf("failed to open env file: %w", err)
51	}
52	defer file.Close()
53
54	var keys []string
55	scanner := bufio.NewScanner(file)
56	for scanner.Scan() {
57		line := strings.TrimSpace(scanner.Text())
58		// Skip empty lines and comments
59		if line == "" || strings.HasPrefix(line, "#") {
60			continue
61		}
62		// Extract key from KEY=value
63		if idx := strings.Index(line, "="); idx > 0 {
64			keys = append(keys, line[:idx])
65		}
66	}
67
68	if err := scanner.Err(); err != nil {
69		return nil, fmt.Errorf("failed to read env file: %w", err)
70	}
71
72	return keys, nil
73}
74
75// GetAll returns all secrets for a domain as a key-value map.
76func (s *FileStore) GetAll(domain string) (map[string]string, error) {
77	envFile := s.getEnvFilePath(domain)
78
79	file, err := os.Open(envFile)
80	if err != nil {
81		if os.IsNotExist(err) {
82			return map[string]string{}, nil
83		}
84		return nil, fmt.Errorf("failed to open env file: %w", err)
85	}
86	defer file.Close()
87
88	secrets := make(map[string]string)
89	scanner := bufio.NewScanner(file)
90	for scanner.Scan() {
91		line := strings.TrimSpace(scanner.Text())
92		// Skip empty lines and comments
93		if line == "" || strings.HasPrefix(line, "#") {
94			continue
95		}
96		// Parse KEY=value
97		if idx := strings.Index(line, "="); idx > 0 {
98			key := line[:idx]
99			value := line[idx+1:]
100			secrets[key] = value
101		}
102	}
103
104	if err := scanner.Err(); err != nil {
105		return nil, fmt.Errorf("failed to read env file: %w", err)
106	}
107
108	return secrets, nil
109}
110
111// Set sets or updates multiple secrets for a domain, merging with existing.
112func (s *FileStore) Set(domain string, secrets map[string]string) error {
113	// Ensure env directory exists
114	if err := os.MkdirAll(s.envDir, 0700); err != nil {
115		return fmt.Errorf("failed to create env directory: %w", err)
116	}
117
118	// Read existing secrets
119	existing, err := s.GetAll(domain)
120	if err != nil {
121		return err
122	}
123
124	// Merge new secrets with existing
125	for key, value := range secrets {
126		existing[key] = value
127	}
128
129	// Write back atomically
130	return s.writeSecretsAtomic(domain, existing)
131}
132
133// Delete removes a specific secret key from a domain.
134func (s *FileStore) Delete(domain, key string) error {
135	// Read existing secrets
136	existing, err := s.GetAll(domain)
137	if err != nil {
138		return err
139	}
140
141	// Remove the key
142	delete(existing, key)
143
144	// Write back atomically
145	return s.writeSecretsAtomic(domain, existing)
146}
147
148// writeSecretsAtomic writes all secrets to the domain's env file atomically.
149// It writes to a temporary file first, syncs it, then renames to the final path.
150func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error {
151	envFile := s.getEnvFilePath(domain)
152	tmpFile := envFile + ".tmp"
153
154	file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
155	if err != nil {
156		return fmt.Errorf("failed to create temp env file: %w", err)
157	}
158
159	// Write header comment
160	if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
161		file.Close()
162		os.Remove(tmpFile)
163		return fmt.Errorf("failed to write header: %w", err)
164	}
165	if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
166		file.Close()
167		os.Remove(tmpFile)
168		return fmt.Errorf("failed to write header: %w", err)
169	}
170
171	// Write each secret
172	for key, value := range secrets {
173		if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
174			file.Close()
175			os.Remove(tmpFile)
176			return fmt.Errorf("failed to write secret %s: %w", key, err)
177		}
178	}
179
180	// Sync to ensure data is on disk before rename
181	if err := file.Sync(); err != nil {
182		file.Close()
183		os.Remove(tmpFile)
184		return fmt.Errorf("failed to sync env file: %w", err)
185	}
186
187	if err := file.Close(); err != nil {
188		os.Remove(tmpFile)
189		return fmt.Errorf("failed to close env file: %w", err)
190	}
191
192	// Atomic rename
193	if err := os.Rename(tmpFile, envFile); err != nil {
194		os.Remove(tmpFile)
195		return fmt.Errorf("failed to rename env file: %w", err)
196	}
197
198	return nil
199}
200
201// getEnvFilePath converts a domain to its env file path.
202// This must match the naming convention in envloader.FileLoader.getEnvFilePath.
203func (s *FileStore) getEnvFilePath(domain string) string {
204	// Create domain-safe filename (replace dots and other chars with underscores)
205	safeDomain := strings.ReplaceAll(domain, ".", "_")
206	safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
207	safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
208	return filepath.Join(s.envDir, safeDomain+".env")
209}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close