Production state
This commit is contained in:
+9
-3
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -24,9 +25,14 @@ func Run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil)
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(cfg.LogLevel)); err != nil {
|
||||
return fmt.Errorf("invalid LOG_LEVEL %q: %w", cfg.LogLevel, err)
|
||||
}
|
||||
handlerOptions := &slog.HandlerOptions{Level: level}
|
||||
var handler slog.Handler = slog.NewTextHandler(os.Stdout, handlerOptions)
|
||||
if cfg.LogFormat == "json" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, nil)
|
||||
handler = slog.NewJSONHandler(os.Stdout, handlerOptions)
|
||||
}
|
||||
log := slog.New(handler)
|
||||
a := auth.New(cfg.StudentPIN, cfg.InstructorPIN, cfg.SessionSecret, cfg.SessionDuration, cfg.Environment == "production")
|
||||
@@ -38,7 +44,7 @@ func Run() error {
|
||||
if err = registry.Register(comic); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{Addr: cfg.Address, Handler: httpserver.New(a, registry, log).Handler(), ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout}
|
||||
srv := &http.Server{Addr: cfg.Address, Handler: httpserver.New(a, registry, log).Handler(), ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout, MaxHeaderBytes: 64 << 10}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
workerDone := make(chan error, 1)
|
||||
|
||||
+12
-1
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
@@ -21,7 +22,7 @@ func LoadConfigFromEnv() (Config, error) {
|
||||
values := []struct {
|
||||
target *time.Duration
|
||||
key, fallback string
|
||||
}{{&c.ReadHeaderTimeout, "HTTP_READ_HEADER_TIMEOUT", "5s"}, {&c.ReadTimeout, "HTTP_READ_TIMEOUT", "30s"}, {&c.WriteTimeout, "HTTP_WRITE_TIMEOUT", "30s"}, {&c.IdleTimeout, "HTTP_IDLE_TIMEOUT", "60s"}, {&c.ShutdownTimeout, "HTTP_SHUTDOWN_TIMEOUT", "10s"}}
|
||||
}{{&c.ReadHeaderTimeout, "HTTP_READ_HEADER_TIMEOUT", "5s"}, {&c.ReadTimeout, "HTTP_READ_TIMEOUT", "30s"}, {&c.WriteTimeout, "HTTP_WRITE_TIMEOUT", "10m"}, {&c.IdleTimeout, "HTTP_IDLE_TIMEOUT", "60s"}, {&c.ShutdownTimeout, "HTTP_SHUTDOWN_TIMEOUT", "10s"}}
|
||||
for _, value := range values {
|
||||
*value.target, err = duration(value.key, value.fallback)
|
||||
if err != nil {
|
||||
@@ -31,6 +32,16 @@ func LoadConfigFromEnv() (Config, error) {
|
||||
if c.StudentPIN == "" || c.InstructorPIN == "" || len(c.SessionSecret) < 32 || c.PublicBaseURL == "" {
|
||||
return c, errors.New("STUDENT_PIN, INSTRUCTOR_PIN, PUBLIC_BASE_URL, and a 32+ character SESSION_SIGNING_SECRET are required")
|
||||
}
|
||||
if c.LogFormat != "text" && c.LogFormat != "json" {
|
||||
return c, errors.New("LOG_FORMAT must be text or json")
|
||||
}
|
||||
publicURL, err := url.Parse(c.PublicBaseURL)
|
||||
if err != nil || publicURL.Host == "" || (publicURL.Scheme != "http" && publicURL.Scheme != "https") || (publicURL.Path != "" && publicURL.Path != "/") || publicURL.RawQuery != "" || publicURL.Fragment != "" || publicURL.User != nil {
|
||||
return c, errors.New("PUBLIC_BASE_URL must be an HTTP(S) origin without a path, query, credentials, or fragment")
|
||||
}
|
||||
if c.Environment == "production" && publicURL.Scheme != "https" {
|
||||
return c, errors.New("PUBLIC_BASE_URL must use HTTPS in production")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func get(k, d string) string {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func setRequiredConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("STUDENT_PIN", "student")
|
||||
t.Setenv("INSTRUCTOR_PIN", "instructor")
|
||||
t.Setenv("SESSION_SIGNING_SECRET", "12345678901234567890123456789012")
|
||||
t.Setenv("LOG_LEVEL", "info")
|
||||
t.Setenv("LOG_FORMAT", "text")
|
||||
}
|
||||
|
||||
func TestProductionConfigRequiresHTTPSPublicOrigin(t *testing.T) {
|
||||
setRequiredConfig(t)
|
||||
t.Setenv("APP_ENV", "production")
|
||||
t.Setenv("PUBLIC_BASE_URL", "http://preface-tools.example.test")
|
||||
if _, err := LoadConfigFromEnv(); err == nil {
|
||||
t.Fatal("production HTTP public URL accepted")
|
||||
}
|
||||
t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test")
|
||||
cfg, err := LoadConfigFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.WriteTimeout != 10*time.Minute {
|
||||
t.Fatalf("write timeout = %s", cfg.WriteTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsPublicURLWithPath(t *testing.T) {
|
||||
setRequiredConfig(t)
|
||||
t.Setenv("APP_ENV", "development")
|
||||
t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test/subpath")
|
||||
if _, err := LoadConfigFromEnv(); err == nil {
|
||||
t.Fatal("public URL with path accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadDotEnv loads simple KEY=VALUE entries without replacing variables that
|
||||
// are already present in the process environment. A missing file is allowed.
|
||||
func LoadDotEnv(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dotenv file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for lineNumber := 1; scanner.Scan(); lineNumber++ {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
if !found || !validEnvironmentKey(key) {
|
||||
return fmt.Errorf("%s:%d: invalid environment assignment", path, lineNumber)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("%s:%d: set environment: %w", path, lineNumber, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("read dotenv file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validEnvironmentKey(key string) bool {
|
||||
if key == "" || !isEnvironmentKeyStart(key[0]) {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(key); i++ {
|
||||
if !isEnvironmentKeyStart(key[i]) && (key[i] < '0' || key[i] > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isEnvironmentKeyStart(char byte) bool {
|
||||
return char == '_' || char >= 'A' && char <= 'Z' || char >= 'a' && char <= 'z'
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDotEnv(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
contents := "# comment\nDOTENV_TEST_ONE=value\nDOTENV_TEST_TWO=Preface Tools - Comic Animator\nDOTENV_TEST_THREE=\"quoted value\"\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DOTENV_TEST_ONE", "existing")
|
||||
if err := LoadDotEnv(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_TEST_ONE"); got != "existing" {
|
||||
t.Fatalf("existing environment overwritten: %q", got)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_TEST_TWO"); got != "Preface Tools - Comic Animator" {
|
||||
t.Fatalf("unquoted value: %q", got)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_TEST_THREE"); got != "quoted value" {
|
||||
t.Fatalf("quoted value: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDotEnvMissingFile(t *testing.T) {
|
||||
if err := LoadDotEnv(filepath.Join(t.TempDir(), "missing")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user