Production state

This commit is contained in:
2026-07-13 01:26:49 +08:00
parent 7cb409f5a6
commit 8c6e6a54cc
21 changed files with 769 additions and 76 deletions
+9 -3
View File
@@ -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
View File
@@ -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 {
+41
View File
@@ -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")
}
}
+65
View File
@@ -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'
}
+34
View File
@@ -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)
}
}