diff --git a/Makefile b/Makefile index a5602dc..bd563a9 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,40 @@ APP_NAME := ric930-fake-smtp +VERSION ?= 1.1.0 + +GO := go BUILD_DIR := builds LINUX_DIR := $(BUILD_DIR)/linux WINDOWS_DIR := $(BUILD_DIR)/windows +RELEASE_DIR := $(BUILD_DIR)/releases LINUX_BIN := $(LINUX_DIR)/$(APP_NAME) WINDOWS_BIN := $(WINDOWS_DIR)/$(APP_NAME).exe -GO := go +LINUX_ARCHIVE := $(RELEASE_DIR)/$(APP_NAME)_$(VERSION)_linux_amd64.tar.gz +WINDOWS_ARCHIVE := $(RELEASE_DIR)/$(APP_NAME)_$(VERSION)_windows_amd64.zip BUILD_FLAGS := -trimpath LDFLAGS := -s -w + .PHONY: all .PHONY: build .PHONY: build-all .PHONY: build-linux .PHONY: build-windows +.PHONY: release +.PHONY: package-linux +.PHONY: package-windows .PHONY: clean .PHONY: fmt .PHONY: tidy .PHONY: check -# ------------------------------------------------------------ -# Host OS -# ------------------------------------------------------------ +# ============================================================ +# Host OS helpers +# ============================================================ ifeq ($(OS),Windows_NT) @@ -68,9 +77,9 @@ endef endif -# ------------------------------------------------------------ -# Default -# ------------------------------------------------------------ +# ============================================================ +# Default targets +# ============================================================ all: build-all @@ -79,9 +88,9 @@ build: build-all build-all: build-linux build-windows -# ------------------------------------------------------------ -# Linux -# ------------------------------------------------------------ +# ============================================================ +# Linux build +# ============================================================ build-linux: @echo "==> Building Linux amd64" @@ -93,12 +102,20 @@ ifeq ($(OS),Windows_NT) @set "CGO_ENABLED=0" && \ set "GOOS=linux" && \ set "GOARCH=amd64" && \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(LINUX_BIN))" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(subst /,\,$(LINUX_BIN))" \ + . else @CGO_ENABLED=0 \ GOOS=linux \ GOARCH=amd64 \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(LINUX_BIN)" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(LINUX_BIN)" \ + . endif $(call copy_file,config.yaml,$(LINUX_DIR)/config.yaml) @@ -107,9 +124,9 @@ endif @echo "==> Linux build created: $(LINUX_DIR)" -# ------------------------------------------------------------ -# Windows -# ------------------------------------------------------------ +# ============================================================ +# Windows build +# ============================================================ build-windows: @echo "==> Building Windows amd64" @@ -121,12 +138,20 @@ ifeq ($(OS),Windows_NT) @set "CGO_ENABLED=0" && \ set "GOOS=windows" && \ set "GOARCH=amd64" && \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(WINDOWS_BIN))" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(subst /,\,$(WINDOWS_BIN))" \ + . else @CGO_ENABLED=0 \ GOOS=windows \ GOARCH=amd64 \ - $(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(WINDOWS_BIN)" . + $(GO) build \ + $(BUILD_FLAGS) \ + -ldflags="$(LDFLAGS)" \ + -o "$(WINDOWS_BIN)" \ + . endif $(call copy_file,config.yaml,$(WINDOWS_DIR)/config.yaml) @@ -135,12 +160,71 @@ endif @echo "==> Windows build created: $(WINDOWS_DIR)" -# ------------------------------------------------------------ +# ============================================================ +# Release +# ============================================================ + +release: build-all package-linux package-windows + @echo "" + @echo "==> Release $(VERSION) created" + @echo " Linux: $(LINUX_ARCHIVE)" + @echo " Windows: $(WINDOWS_ARCHIVE)" + + +# ============================================================ +# Linux package +# ============================================================ + +package-linux: + @echo "==> Creating Linux release archive" + $(call make_dir,$(RELEASE_DIR)) + +ifeq ($(OS),Windows_NT) + @tar \ + -C "$(subst /,\,$(LINUX_DIR))" \ + -czf "$(subst /,\,$(LINUX_ARCHIVE))" \ + . +else + @tar \ + -C "$(LINUX_DIR)" \ + -czf "$(LINUX_ARCHIVE)" \ + . +endif + + @echo "==> Linux archive created: $(LINUX_ARCHIVE)" + + +# ============================================================ +# Windows package +# ============================================================ + +package-windows: + @echo "==> Creating Windows release archive" + $(call make_dir,$(RELEASE_DIR)) + +ifeq ($(OS),Windows_NT) + @powershell -NoProfile -Command \ + "Compress-Archive \ + -Path '$(subst /,\,$(WINDOWS_DIR))\*' \ + -DestinationPath '$(subst /,\,$(WINDOWS_ARCHIVE))' \ + -Force" +else + @rm -f "$(WINDOWS_ARCHIVE)" + @cd "$(WINDOWS_DIR)" && \ + zip -qr \ + "../releases/$(APP_NAME)_$(VERSION)_windows_amd64.zip" \ + . +endif + + @echo "==> Windows archive created: $(WINDOWS_ARCHIVE)" + + +# ============================================================ # Maintenance -# ------------------------------------------------------------ +# ============================================================ clean: - @echo "==> Cleaning builds" + @echo "==> Cleaning build directory" $(call remove_dir,$(BUILD_DIR)) @@ -155,7 +239,7 @@ tidy: check: - @echo "==> Checking project" + @echo "==> Running Go checks" @$(GO) vet ./... @$(GO) test ./... diff --git a/config.go b/config.go index 81fb50d..18f8d82 100644 --- a/config.go +++ b/config.go @@ -1,10 +1,12 @@ package main import ( + "bytes" "fmt" "log" "os" "path/filepath" + "time" "gopkg.in/yaml.v3" ) @@ -24,16 +26,52 @@ type SMTPConfig struct { } type WebConfig struct { - Enabled bool `yaml:"enabled"` - Port int `yaml:"port"` - Directory string `yaml:"directory"` - BasicAuth BasicAuthConfig `yaml:"basic_auth"` + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + Directory string `yaml:"directory"` + Auth AuthConfig `yaml:"auth"` } -type BasicAuthConfig struct { - Enabled bool `yaml:"enabled"` - Username string `yaml:"username"` - Password string `yaml:"password"` +type AuthConfig struct { + Enabled bool `yaml:"enabled"` + Username string `yaml:"username"` + Password string `yaml:"password"` + SessionTimeout string `yaml:"session_timeout"` + CookieSecure bool `yaml:"cookie_secure"` + + LoginMaxAttempts int `yaml:"login_max_attempts"` + LoginWindow string `yaml:"login_window"` + LoginLockout string `yaml:"login_lockout"` +} + +func defaultConfig() *Config { + return &Config{ + SMTP: SMTPConfig{ + ListenAddress: "0.0.0.0", + ListenPort: 25, + StorageDir: "emails", + DomainName: "m.ric930.ru", + MaxMessageSize: 50 * 1024 * 1024, + EnableAuth: true, + }, + + Web: WebConfig{ + Enabled: true, + Port: 8089, + Directory: "web", + + Auth: AuthConfig{ + Enabled: true, + Username: "admin", + Password: "admin", + SessionTimeout: "15m", + CookieSecure: false, + LoginMaxAttempts: 3, + LoginWindow: "5m", + LoginLockout: "15m", + }, + }, + } } func getExecutableDir() (string, error) { @@ -42,138 +80,308 @@ func getExecutableDir() (string, error) { return "", err } + exe, err = filepath.Abs(exe) + if err != nil { + return "", err + } + return filepath.Dir(exe), nil } -func defaultConfig() *Config { - return &Config{ - SMTP: SMTPConfig{ - ListenAddress: "0.0.0.0", - ListenPort: 1025, - StorageDir: "emails", - DomainName: "m.ric930.ru", - MaxMessageSize: 50 * 1024 * 1024, - EnableAuth: true, - }, - Web: WebConfig{ - Enabled: true, - Port: 8089, - Directory: "web", - BasicAuth: BasicAuthConfig{ - Enabled: true, - Username: "admin", - Password: "admin", - }, - }, - } -} +func resolveConfigPaths( + cfg *Config, + exeDir string, +) { + if cfg.SMTP.StorageDir != "" && + !filepath.IsAbs(cfg.SMTP.StorageDir) { -func resolveConfigPaths(cfg *Config, exeDir string) { - if !filepath.IsAbs(cfg.SMTP.StorageDir) { - cfg.SMTP.StorageDir = filepath.Join(exeDir, cfg.SMTP.StorageDir) + cfg.SMTP.StorageDir = filepath.Join( + exeDir, + cfg.SMTP.StorageDir, + ) } - if !filepath.IsAbs(cfg.Web.Directory) { - cfg.Web.Directory = filepath.Join(exeDir, cfg.Web.Directory) + if cfg.Web.Directory != "" && + !filepath.IsAbs(cfg.Web.Directory) { + + cfg.Web.Directory = filepath.Join( + exeDir, + cfg.Web.Directory, + ) } } func validateConfig(cfg *Config) error { - if cfg.SMTP.ListenPort < 1 || cfg.SMTP.ListenPort > 65535 { - return fmt.Errorf("invalid smtp.listen_port: %d", cfg.SMTP.ListenPort) + if cfg.SMTP.ListenPort < 1 || + cfg.SMTP.ListenPort > 65535 { + + return fmt.Errorf( + "smtp.listen_port must be between 1 and 65535", + ) } if cfg.SMTP.StorageDir == "" { - return fmt.Errorf("smtp.storage_dir must not be empty") + return fmt.Errorf( + "smtp.storage_dir must not be empty", + ) } if cfg.SMTP.DomainName == "" { - return fmt.Errorf("smtp.domain_name must not be empty") + return fmt.Errorf( + "smtp.domain_name must not be empty", + ) } if cfg.SMTP.MaxMessageSize <= 0 { - return fmt.Errorf("smtp.max_message_size must be greater than zero") + return fmt.Errorf( + "smtp.max_message_size must be greater than zero", + ) } - if cfg.Web.Enabled { - if cfg.Web.Port < 1 || cfg.Web.Port > 65535 { - return fmt.Errorf("invalid web.port: %d", cfg.Web.Port) - } + if !cfg.Web.Enabled { + return nil + } - if cfg.Web.Directory == "" { - return fmt.Errorf("web.directory must not be empty") - } + if cfg.Web.Port < 1 || + cfg.Web.Port > 65535 { - if cfg.Web.BasicAuth.Enabled { - if cfg.Web.BasicAuth.Username == "" { - return fmt.Errorf("web.basic_auth.username must not be empty") - } + return fmt.Errorf( + "web.port must be between 1 and 65535", + ) + } - if cfg.Web.BasicAuth.Password == "" { - return fmt.Errorf("web.basic_auth.password must not be empty") - } - } + if cfg.Web.Directory == "" { + return fmt.Errorf( + "web.directory must not be empty", + ) + } + + if !cfg.Web.Auth.Enabled { + return nil + } + + if cfg.Web.Auth.Username == "" { + return fmt.Errorf( + "web.auth.username must not be empty", + ) + } + + if cfg.Web.Auth.Password == "" { + return fmt.Errorf( + "web.auth.password must not be empty", + ) + } + + if cfg.Web.Auth.SessionTimeout == "" { + return fmt.Errorf( + "web.auth.session_timeout must not be empty", + ) + } + + sessionTimeout, err := time.ParseDuration( + cfg.Web.Auth.SessionTimeout, + ) + if err != nil { + return fmt.Errorf( + "invalid web.auth.session_timeout %q: %w", + cfg.Web.Auth.SessionTimeout, + err, + ) + } + + if sessionTimeout <= 0 { + return fmt.Errorf( + "web.auth.session_timeout must be greater than zero", + ) + } + + if cfg.Web.Auth.LoginMaxAttempts <= 0 { + return fmt.Errorf( + "web.auth.login_max_attempts must be greater than zero", + ) + } + + loginWindow, err := time.ParseDuration( + cfg.Web.Auth.LoginWindow, + ) + if err != nil { + return fmt.Errorf( + "invalid web.auth.login_window %q: %w", + cfg.Web.Auth.LoginWindow, + err, + ) + } + + if loginWindow <= 0 { + return fmt.Errorf( + "web.auth.login_window must be greater than zero", + ) + } + + loginLockout, err := time.ParseDuration( + cfg.Web.Auth.LoginLockout, + ) + if err != nil { + return fmt.Errorf( + "invalid web.auth.login_lockout %q: %w", + cfg.Web.Auth.LoginLockout, + err, + ) + } + + if loginLockout <= 0 { + return fmt.Errorf( + "web.auth.login_lockout must be greater than zero", + ) } return nil } -func LoadConfig(configPath string) (*Config, error) { +func LoadConfig( + configPath string, +) (*Config, error) { exeDir, err := getExecutableDir() if err != nil { log.Printf( "Warning: cannot get executable directory: %v, using current directory", err, ) + exeDir = "." } - finalConfigPath := configPath - if !filepath.IsAbs(finalConfigPath) { - finalConfigPath = filepath.Join(exeDir, finalConfigPath) + if configPath == "" { + configPath = "config.yaml" } - data, err := os.ReadFile(finalConfigPath) + finalConfigPath := configPath + + if !filepath.IsAbs(finalConfigPath) { + finalConfigPath = filepath.Join( + exeDir, + finalConfigPath, + ) + } + + finalConfigPath, err = filepath.Abs( + finalConfigPath, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to resolve config path: %w", + err, + ) + } + + log.Printf( + "Loading config from %s", + finalConfigPath, + ) + + data, err := os.ReadFile( + finalConfigPath, + ) + if err != nil { if !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read config: %w", err) + return nil, fmt.Errorf( + "failed to read config %s: %w", + finalConfigPath, + err, + ) } + // Конфига нет вообще: + // создаём новый default config. cfg := defaultConfig() - cfgData, marshalErr := yaml.Marshal(cfg) - if marshalErr != nil { + if err := validateConfig(cfg); err != nil { return nil, fmt.Errorf( - "failed to create default config data: %w", - marshalErr, + "default config is invalid: %w", + err, ) } - if writeErr := os.WriteFile(finalConfigPath, cfgData, 0600); writeErr != nil { + cfgData, err := yaml.Marshal(cfg) + if err != nil { return nil, fmt.Errorf( - "failed to create default config: %w", - writeErr, + "failed to encode default config: %w", + err, ) } - log.Printf("Created default config at %s", finalConfigPath) - log.Printf( - "WARNING: change the default web Basic Auth password in %s", + configDir := filepath.Dir( finalConfigPath, ) - resolveConfigPaths(cfg, exeDir) - - if err := validateConfig(cfg); err != nil { - return nil, err + if err := os.MkdirAll( + configDir, + 0755, + ); err != nil { + return nil, fmt.Errorf( + "failed to create config directory %s: %w", + configDir, + err, + ) } + if err := os.WriteFile( + finalConfigPath, + cfgData, + 0600, + ); err != nil { + return nil, fmt.Errorf( + "failed to create default config %s: %w", + finalConfigPath, + err, + ) + } + + log.Printf( + "Created default config at %s", + finalConfigPath, + ) + + resolveConfigPaths( + cfg, + exeDir, + ) + return cfg, nil } + /* + ВАЖНО: + + Если config.yaml существует, НЕ используем defaultConfig() + как основу. + + Иначе ошибочное/отсутствующее поле может незаметно получить + значение, зашитое в бинарник. + */ var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + decoder := yaml.NewDecoder( + bytes.NewReader(data), + ) + + /* + Запрещаем неизвестные поля. + + Например старое: + + basic_auth: + + вместо: + + auth: + + теперь вызовет ошибку при запуске. + */ + decoder.KnownFields(true) + + if err := decoder.Decode(&cfg); err != nil { return nil, fmt.Errorf( "failed to parse YAML config %s: %w", finalConfigPath, @@ -189,7 +397,17 @@ func LoadConfig(configPath string) (*Config, error) { ) } - resolveConfigPaths(&cfg, exeDir) + log.Printf( + "Config loaded: web_auth=%v username=%q session_timeout=%s", + cfg.Web.Auth.Enabled, + cfg.Web.Auth.Username, + cfg.Web.Auth.SessionTimeout, + ) + + resolveConfigPaths( + &cfg, + exeDir, + ) return &cfg, nil } diff --git a/config.yaml b/config.yaml index 3d36277..2d6aeb3 100644 --- a/config.yaml +++ b/config.yaml @@ -11,7 +11,14 @@ web: port: 8089 directory: web - basic_auth: + auth: enabled: true + session_timeout: 15m + cookie_secure: false + username: admin password: admin + + login_max_attempts: 5 + login_window: 5m + login_lockout: 15m diff --git a/main.go b/main.go index 99a43d2..cc26633 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,6 @@ type Program struct { webServer *WebServer } - func (p *Program) Start( s service.Service, ) error { diff --git a/web/app.js b/web/app.js index e3456dd..509727f 100644 --- a/web/app.js +++ b/web/app.js @@ -1,56 +1,88 @@ 'use strict'; -document.addEventListener('DOMContentLoaded', function () { - initializeEmailList(); -}); +var iframeObservers = new WeakMap(); + +document.addEventListener( + 'DOMContentLoaded', + function () { + initializeEmailList(); + } +); function initializeEmailList() { - var emailItems = document.querySelectorAll('.email-item'); + var emailItems = + document.querySelectorAll('.email-item'); emailItems.forEach(function (item) { - var summary = item.querySelector('.email-summary'); - var downloadButton = item.querySelector('.download-icon'); + var summary = + item.querySelector('.email-summary'); + + var downloadButton = + item.querySelector('.download-icon'); if (summary) { - summary.addEventListener('click', function (event) { - if (event.target.closest('.download-icon')) { - return; + summary.addEventListener( + 'click', + function (event) { + if ( + event.target.closest( + '.download-icon' + ) + ) { + return; + } + + toggleEmail(item); } + ); - toggleEmail(item); - }); + summary.addEventListener( + 'keydown', + function (event) { + if ( + event.key !== 'Enter' && + event.key !== ' ' + ) { + return; + } - summary.addEventListener('keydown', function (event) { - if ( - event.key !== 'Enter' && - event.key !== ' ' - ) { - return; + if ( + event.target.closest( + '.download-icon' + ) + ) { + return; + } + + event.preventDefault(); + + toggleEmail(item); } - - if (event.target.closest('.download-icon')) { - return; - } - - event.preventDefault(); - - toggleEmail(item); - }); + ); } if (downloadButton) { - downloadButton.addEventListener('click', function (event) { - event.stopPropagation(); + downloadButton.addEventListener( + 'click', + function (event) { + event.stopPropagation(); - var id = item.dataset.id; + var id = item.dataset.id; - if (!id) { - console.error('Email ID is missing'); - return; + if (!id) { + console.error( + 'Email ID is missing' + ); + + return; + } + + downloadEmail( + event, + id + ); } - - downloadEmail(event, id); - }); + ); } }); } @@ -59,14 +91,26 @@ function toggleEmail(item) { var id = item.dataset.id; if (!id) { - console.error('Email ID is missing'); + console.error( + 'Email ID is missing' + ); + return; } - var summary = item.querySelector('.email-summary'); - var detail = item.querySelector('.email-detail'); - var toggleIcon = item.querySelector('.toggle'); - var iframe = item.querySelector('.email-content-frame'); + var summary = + item.querySelector('.email-summary'); + + var detail = + item.querySelector('.email-detail'); + + var toggleIcon = + item.querySelector('.toggle'); + + var iframe = + item.querySelector( + '.email-content-frame' + ); if (!detail || !iframe) { console.error( @@ -77,13 +121,16 @@ function toggleEmail(item) { return; } - var isHidden = detail.classList.contains('hidden'); + var isHidden = + detail.classList.contains('hidden'); if (isHidden) { detail.classList.remove('hidden'); if (toggleIcon) { - toggleIcon.classList.add('rotated'); + toggleIcon.classList.add( + 'rotated' + ); } if (summary) { @@ -101,7 +148,9 @@ function toggleEmail(item) { detail.classList.add('hidden'); if (toggleIcon) { - toggleIcon.classList.remove('rotated'); + toggleIcon.classList.remove( + 'rotated' + ); } if (summary) { @@ -114,11 +163,19 @@ function toggleEmail(item) { } function loadEmailContent(id, iframe) { - if (iframe.dataset.loaded === 'true') { + if ( + iframe.dataset.loaded === 'true' + ) { resizeIframe(iframe); return; } + if ( + iframe.dataset.loading === 'true' + ) { + return; + } + iframe.dataset.loading = 'true'; fetch( @@ -130,6 +187,8 @@ function loadEmailContent(id, iframe) { } ) .then(function (response) { + handleUnauthorized(response); + if (!response.ok) { throw new Error( 'HTTP error ' + @@ -140,12 +199,15 @@ function loadEmailContent(id, iframe) { return response.text(); }) .then(function (data) { + resetIframeObserver(iframe); + writeEmailToIframe( iframe, data ); - iframe.dataset.loaded = 'true'; + iframe.dataset.loaded = + 'true'; delete iframe.dataset.loading; @@ -154,6 +216,17 @@ function loadEmailContent(id, iframe) { .catch(function (error) { delete iframe.dataset.loading; + // При 401 уже выполняется redirect. + if ( + error && + error.message === + 'Session expired' + ) { + return; + } + + resetIframeObserver(iframe); + writeErrorToIframe( iframe, error.message @@ -167,71 +240,94 @@ function loadEmailContent(id, iframe) { }); } -function writeEmailToIframe(iframe, emailBody) { - var doc = iframe.contentWindow.document; +function handleUnauthorized(response) { + if (response.status !== 401) { + return; + } + + window.location.href = '/login'; + + throw new Error( + 'Session expired' + ); +} + +function writeEmailToIframe( + iframe, + emailBody +) { + var doc = + iframe.contentWindow.document; doc.open(); doc.write( '' + + '' + + '
' + + '' + - '' + + + '' + '+ Email storage +
+- Email storage -