diff --git a/README.md b/README.md index fe46474..095d8d9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,37 @@ A simple and efficient solution for serving static files. - **Lightweight**: Minimal dependencies for optimal performance. - **Configurable**: You can easily configure the server or extend it based on your needs. - The server serves files from the `static` directory by default, but you can change this by setting the `STATIC_DIR_PATH` environment variable. - - Support all the confgs of the gofr framework - https://gofr.dev + - Support all the configs of the gofr framework - https://gofr.dev + +## Config File Hydration + +When the `CONFIG_FILE_PATH` environment variable is set, the server replaces any `${VAR}` placeholders in that file at startup using values from the environment (including `.env` files). The file is rewritten in-place before serving begins. + +This is useful for injecting runtime configuration into static front-end apps without rebuilding them. + +If any placeholders have no matching environment variable, the server still writes the file (substituting empty strings for missing values) and logs an error listing the unresolved variables. + +#### Example + +Given a `config.json` template: + +```json +{ + "clientId": "${GOOGLE_CLIENT_ID}", + "apiUrl": "${API_BASE_URL}" +} +``` + +If `GOOGLE_CLIENT_ID=abc123` and `API_BASE_URL=https://api.example.com` are set, the file becomes: + +```json +{ + "clientId": "abc123", + "apiUrl": "https://api.example.com" +} +``` + +> See the [example Dockerfile](#1-build-a-docker-image) below for how to set `CONFIG_FILE_PATH`. ## Usage @@ -22,20 +52,20 @@ To deploy the server, you need to build a Docker image using the provided `Docke ```dockerfile # Use the official static-server image as the base image # This will pull the prebuilt version of the static-server to run your static website -FROM zopdev/static-server:v0.0.7 +FROM zopdev/static-server:v0.0.8 # Copy static files into the container -# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory +# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/static' directory # which is where the static server expects to find the files to serve COPY /app/out /static -# Expose the port on which the server will run -# By default, the server listens on port 8000, so we expose that port to allow access from outside the container -EXPOSE 8000 +# Set the path to the config file for environment variable hydration at startup +ENV CONFIG_FILE_PATH=/static/config.json + +# The server listens on port 8000 by default; set HTTP_PORT to change it # Define the command to run the server -# The static server is started with the '/main' binary included in the image, which will start serving -# the files from the '/website' directory on port 8000 +# The static server is started with the '/main' binary included in the image CMD ["/main"] ``` @@ -75,7 +105,7 @@ Your static files will be served, and the root (`/`) will typically display your ## Notes -- The server serves all files in the `website` directory, so make sure to avoid any sensitive files or configuration details in that directory. +- The server serves all files in the `static` directory, so make sure to avoid any sensitive files or configuration details in that directory. ## License diff --git a/go.mod b/go.mod index d165ee2..b9457b2 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module zop.dev/static-server go 1.25.0 -require gofr.dev v1.54.3 +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 + gofr.dev v1.54.3 +) require ( cloud.google.com/go v0.123.0 // indirect @@ -57,7 +61,6 @@ require ( github.com/redis/go-redis/v9 v9.17.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/kafka-go v0.4.50 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -76,7 +79,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go new file mode 100644 index 0000000..69fb91e --- /dev/null +++ b/internal/config/hydrate.go @@ -0,0 +1,83 @@ +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/datasource/file" +) + +var ( + errMissingVars = errors.New("missing config variables") + errReadConfig = errors.New("failed to read config file") + errWriteConfig = errors.New("failed to write config file") + + envVarRe = regexp.MustCompile(`\$\{(\w+)\}`) +) + +const filePathVar = "CONFIG_FILE_PATH" + +// HydrateFile reads the file at the path specified by the CONFIG_FILE_PATH config +// variable, substitutes every ${VAR} placeholder with the corresponding value +// obtained from cfg, and writes the result back to the same file. +// +// If CONFIG_FILE_PATH is not set (empty string), HydrateFile is a no-op and +// returns nil. If any referenced variable is not present in the config, an +// errMissingVars error is returned after the file has been written. +// +// cfg is accepted as a config.Config interface rather than as individual values +// so that the set of variables resolved is open-ended: the file may reference +// any variable, and HydrateFile resolves each one dynamically via cfg.Get +// without the caller needing to know which variables the file contains. +func HydrateFile(fs file.FileSystem, cfg config.Config) error { + configPath := cfg.Get(filePathVar) + if configPath == "" { + return nil + } + + configFile, err := fs.Open(filepath.Clean(configPath)) + if err != nil { + return fmt.Errorf("%w: %w", errReadConfig, err) + } + + content, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("%w: %w", errReadConfig, err) + } + + _ = configFile.Close() + + // Hydrate with available vars + result := os.Expand(string(content), cfg.Get) + + wf, err := fs.OpenFile(configPath, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + return fmt.Errorf("%w: %w", errWriteConfig, err) + } + + if _, err = wf.Write([]byte(result)); err != nil { + return fmt.Errorf("%w: %w", errWriteConfig, err) + } + + // Detect vars that were missing (replaced with empty string) + matches := envVarRe.FindAllStringSubmatch(string(content), -1) + + var missing []string + + for _, m := range matches { + if cfg.Get(m[1]) == "" { + missing = append(missing, m[1]) + } + } + + if len(missing) > 0 { + return fmt.Errorf("%w: %v", errMissingVars, missing) + } + + return nil +} diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go new file mode 100644 index 0000000..613e3f2 --- /dev/null +++ b/internal/config/hydrate_test.go @@ -0,0 +1,121 @@ +package config + +import ( + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/datasource/file" + "gofr.dev/pkg/gofr/logging" +) + +func writeTempFile(t *testing.T, fs file.FileSystem, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + f, err := fs.Create(path) + require.NoError(t, err) + + _, err = f.Write([]byte(content)) + require.NoError(t, err) + + require.NoError(t, f.Close()) + + return path +} + +func TestConfig(t *testing.T) { + tests := []struct { + name string + template string + vars map[string]string + expected string + wantErr error + }{ + { + name: "all vars present", + template: `{"a":"${A}","b":"${B}"}`, + vars: map[string]string{"A": "1", "B": "2"}, + expected: `{"a":"1","b":"2"}`, + }, + { + name: "no config path is a no-op", + vars: map[string]string{}, + wantErr: nil, + }, + { + name: "extra vars ignored", + template: `{"a":"${A}"}`, + vars: map[string]string{"A": "1", "EXTRA": "x"}, + expected: `{"a":"1"}`, + }, + { + name: "partial vars missing", + template: `{"a":"${A}","b":"${MISSING}"}`, + vars: map[string]string{"A": "1"}, + expected: `{"a":"1","b":""}`, + wantErr: errMissingVars, + }, + { + name: "all vars missing", + template: `{"a":"${X}","b":"${Y}"}`, + vars: map[string]string{}, + expected: `{"a":"","b":""}`, + wantErr: errMissingVars, + }, + { + name: "invalid config path", + vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"}, + wantErr: errReadConfig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := file.New(logging.NewMockLogger(logging.ERROR)) + + if tt.template != "" { + path := writeTempFile(t, fs, tt.template) + tt.vars[filePathVar] = path + } + + err := HydrateFile(fs, config.NewMockConfig(tt.vars)) + + require.ErrorIs(t, err, tt.wantErr) + + if tt.expected != "" { + rf, readErr := fs.Open(tt.vars[filePathVar]) + require.NoError(t, readErr) + got, readErr := io.ReadAll(rf) + require.NoError(t, readErr) + require.Equal(t, tt.expected, string(got)) + } + }) + } +} + +func TestHydrateFile_WriteError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod not effective on Windows") + } + + fs := file.New(logging.NewMockLogger(logging.ERROR)) + path := writeTempFile(t, fs, `{"a":"${A}"}`) + + err := os.Chmod(path, 0444) + require.NoError(t, err) + + vars := map[string]string{ + filePathVar: path, + "A": "1", + } + + err = HydrateFile(fs, config.NewMockConfig(vars)) + require.ErrorIs(t, err, errWriteConfig) +} diff --git a/main.go b/main.go index f510abe..249a62c 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,8 @@ import ( "strings" "gofr.dev/pkg/gofr" + + "zop.dev/static-server/internal/config" ) const defaultStaticFilePath = `./static` @@ -18,6 +20,14 @@ const rootPath = "/" func main() { app := gofr.New() + app.OnStart(func(ctx *gofr.Context) error { + if err := config.HydrateFile(ctx.File, app.Config); err != nil { + ctx.Logger.Error(err) + } + + return nil + }) + staticFilePath := app.Config.GetOrDefault("STATIC_DIR_PATH", defaultStaticFilePath) app.UseMiddleware(func(h http.Handler) http.Handler {