release: 1.1.1 - session auth and security improvements
This commit is contained in:
@@ -1,31 +1,40 @@
|
|||||||
APP_NAME := ric930-fake-smtp
|
APP_NAME := ric930-fake-smtp
|
||||||
|
VERSION ?= 1.1.0
|
||||||
|
|
||||||
|
GO := go
|
||||||
|
|
||||||
BUILD_DIR := builds
|
BUILD_DIR := builds
|
||||||
LINUX_DIR := $(BUILD_DIR)/linux
|
LINUX_DIR := $(BUILD_DIR)/linux
|
||||||
WINDOWS_DIR := $(BUILD_DIR)/windows
|
WINDOWS_DIR := $(BUILD_DIR)/windows
|
||||||
|
RELEASE_DIR := $(BUILD_DIR)/releases
|
||||||
|
|
||||||
LINUX_BIN := $(LINUX_DIR)/$(APP_NAME)
|
LINUX_BIN := $(LINUX_DIR)/$(APP_NAME)
|
||||||
WINDOWS_BIN := $(WINDOWS_DIR)/$(APP_NAME).exe
|
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
|
BUILD_FLAGS := -trimpath
|
||||||
LDFLAGS := -s -w
|
LDFLAGS := -s -w
|
||||||
|
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
.PHONY: build-all
|
.PHONY: build-all
|
||||||
.PHONY: build-linux
|
.PHONY: build-linux
|
||||||
.PHONY: build-windows
|
.PHONY: build-windows
|
||||||
|
.PHONY: release
|
||||||
|
.PHONY: package-linux
|
||||||
|
.PHONY: package-windows
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
.PHONY: check
|
.PHONY: check
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
# Host OS
|
# Host OS helpers
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
|
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
|
|
||||||
@@ -68,9 +77,9 @@ endef
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
# Default
|
# Default targets
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
|
|
||||||
all: build-all
|
all: build-all
|
||||||
|
|
||||||
@@ -79,9 +88,9 @@ build: build-all
|
|||||||
build-all: build-linux build-windows
|
build-all: build-linux build-windows
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
# Linux
|
# Linux build
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
@echo "==> Building Linux amd64"
|
@echo "==> Building Linux amd64"
|
||||||
@@ -93,12 +102,20 @@ ifeq ($(OS),Windows_NT)
|
|||||||
@set "CGO_ENABLED=0" && \
|
@set "CGO_ENABLED=0" && \
|
||||||
set "GOOS=linux" && \
|
set "GOOS=linux" && \
|
||||||
set "GOARCH=amd64" && \
|
set "GOARCH=amd64" && \
|
||||||
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(LINUX_BIN))" .
|
$(GO) build \
|
||||||
|
$(BUILD_FLAGS) \
|
||||||
|
-ldflags="$(LDFLAGS)" \
|
||||||
|
-o "$(subst /,\,$(LINUX_BIN))" \
|
||||||
|
.
|
||||||
else
|
else
|
||||||
@CGO_ENABLED=0 \
|
@CGO_ENABLED=0 \
|
||||||
GOOS=linux \
|
GOOS=linux \
|
||||||
GOARCH=amd64 \
|
GOARCH=amd64 \
|
||||||
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(LINUX_BIN)" .
|
$(GO) build \
|
||||||
|
$(BUILD_FLAGS) \
|
||||||
|
-ldflags="$(LDFLAGS)" \
|
||||||
|
-o "$(LINUX_BIN)" \
|
||||||
|
.
|
||||||
endif
|
endif
|
||||||
|
|
||||||
$(call copy_file,config.yaml,$(LINUX_DIR)/config.yaml)
|
$(call copy_file,config.yaml,$(LINUX_DIR)/config.yaml)
|
||||||
@@ -107,9 +124,9 @@ endif
|
|||||||
@echo "==> Linux build created: $(LINUX_DIR)"
|
@echo "==> Linux build created: $(LINUX_DIR)"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
# Windows
|
# Windows build
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
@echo "==> Building Windows amd64"
|
@echo "==> Building Windows amd64"
|
||||||
@@ -121,12 +138,20 @@ ifeq ($(OS),Windows_NT)
|
|||||||
@set "CGO_ENABLED=0" && \
|
@set "CGO_ENABLED=0" && \
|
||||||
set "GOOS=windows" && \
|
set "GOOS=windows" && \
|
||||||
set "GOARCH=amd64" && \
|
set "GOARCH=amd64" && \
|
||||||
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(subst /,\,$(WINDOWS_BIN))" .
|
$(GO) build \
|
||||||
|
$(BUILD_FLAGS) \
|
||||||
|
-ldflags="$(LDFLAGS)" \
|
||||||
|
-o "$(subst /,\,$(WINDOWS_BIN))" \
|
||||||
|
.
|
||||||
else
|
else
|
||||||
@CGO_ENABLED=0 \
|
@CGO_ENABLED=0 \
|
||||||
GOOS=windows \
|
GOOS=windows \
|
||||||
GOARCH=amd64 \
|
GOARCH=amd64 \
|
||||||
$(GO) build $(BUILD_FLAGS) -ldflags="$(LDFLAGS)" -o "$(WINDOWS_BIN)" .
|
$(GO) build \
|
||||||
|
$(BUILD_FLAGS) \
|
||||||
|
-ldflags="$(LDFLAGS)" \
|
||||||
|
-o "$(WINDOWS_BIN)" \
|
||||||
|
.
|
||||||
endif
|
endif
|
||||||
|
|
||||||
$(call copy_file,config.yaml,$(WINDOWS_DIR)/config.yaml)
|
$(call copy_file,config.yaml,$(WINDOWS_DIR)/config.yaml)
|
||||||
@@ -135,12 +160,71 @@ endif
|
|||||||
@echo "==> Windows build created: $(WINDOWS_DIR)"
|
@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
|
# Maintenance
|
||||||
# ------------------------------------------------------------
|
# ============================================================
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "==> Cleaning builds"
|
@echo "==> Cleaning build directory"
|
||||||
$(call remove_dir,$(BUILD_DIR))
|
$(call remove_dir,$(BUILD_DIR))
|
||||||
|
|
||||||
|
|
||||||
@@ -155,7 +239,7 @@ tidy:
|
|||||||
|
|
||||||
|
|
||||||
check:
|
check:
|
||||||
@echo "==> Checking project"
|
@echo "==> Running Go checks"
|
||||||
@$(GO) vet ./...
|
@$(GO) vet ./...
|
||||||
@$(GO) test ./...
|
@$(GO) test ./...
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -24,16 +26,52 @@ type SMTPConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
Directory string `yaml:"directory"`
|
Directory string `yaml:"directory"`
|
||||||
BasicAuth BasicAuthConfig `yaml:"basic_auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BasicAuthConfig struct {
|
type AuthConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
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) {
|
func getExecutableDir() (string, error) {
|
||||||
@@ -42,138 +80,308 @@ func getExecutableDir() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exe, err = filepath.Abs(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return filepath.Dir(exe), nil
|
return filepath.Dir(exe), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultConfig() *Config {
|
func resolveConfigPaths(
|
||||||
return &Config{
|
cfg *Config,
|
||||||
SMTP: SMTPConfig{
|
exeDir string,
|
||||||
ListenAddress: "0.0.0.0",
|
) {
|
||||||
ListenPort: 1025,
|
if cfg.SMTP.StorageDir != "" &&
|
||||||
StorageDir: "emails",
|
!filepath.IsAbs(cfg.SMTP.StorageDir) {
|
||||||
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) {
|
cfg.SMTP.StorageDir = filepath.Join(
|
||||||
if !filepath.IsAbs(cfg.SMTP.StorageDir) {
|
exeDir,
|
||||||
cfg.SMTP.StorageDir = filepath.Join(exeDir, cfg.SMTP.StorageDir)
|
cfg.SMTP.StorageDir,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !filepath.IsAbs(cfg.Web.Directory) {
|
if cfg.Web.Directory != "" &&
|
||||||
cfg.Web.Directory = filepath.Join(exeDir, cfg.Web.Directory)
|
!filepath.IsAbs(cfg.Web.Directory) {
|
||||||
|
|
||||||
|
cfg.Web.Directory = filepath.Join(
|
||||||
|
exeDir,
|
||||||
|
cfg.Web.Directory,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateConfig(cfg *Config) error {
|
func validateConfig(cfg *Config) error {
|
||||||
if cfg.SMTP.ListenPort < 1 || cfg.SMTP.ListenPort > 65535 {
|
if cfg.SMTP.ListenPort < 1 ||
|
||||||
return fmt.Errorf("invalid smtp.listen_port: %d", cfg.SMTP.ListenPort)
|
cfg.SMTP.ListenPort > 65535 {
|
||||||
|
|
||||||
|
return fmt.Errorf(
|
||||||
|
"smtp.listen_port must be between 1 and 65535",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.SMTP.StorageDir == "" {
|
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 == "" {
|
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 {
|
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.Enabled {
|
||||||
if cfg.Web.Port < 1 || cfg.Web.Port > 65535 {
|
return nil
|
||||||
return fmt.Errorf("invalid web.port: %d", cfg.Web.Port)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Web.Directory == "" {
|
if cfg.Web.Port < 1 ||
|
||||||
return fmt.Errorf("web.directory must not be empty")
|
cfg.Web.Port > 65535 {
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Web.BasicAuth.Enabled {
|
return fmt.Errorf(
|
||||||
if cfg.Web.BasicAuth.Username == "" {
|
"web.port must be between 1 and 65535",
|
||||||
return fmt.Errorf("web.basic_auth.username must not be empty")
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Web.BasicAuth.Password == "" {
|
if cfg.Web.Directory == "" {
|
||||||
return fmt.Errorf("web.basic_auth.password must not be empty")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(configPath string) (*Config, error) {
|
func LoadConfig(
|
||||||
|
configPath string,
|
||||||
|
) (*Config, error) {
|
||||||
exeDir, err := getExecutableDir()
|
exeDir, err := getExecutableDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"Warning: cannot get executable directory: %v, using current directory",
|
"Warning: cannot get executable directory: %v, using current directory",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
|
|
||||||
exeDir = "."
|
exeDir = "."
|
||||||
}
|
}
|
||||||
|
|
||||||
finalConfigPath := configPath
|
if configPath == "" {
|
||||||
if !filepath.IsAbs(finalConfigPath) {
|
configPath = "config.yaml"
|
||||||
finalConfigPath = filepath.Join(exeDir, finalConfigPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 err != nil {
|
||||||
if !os.IsNotExist(err) {
|
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()
|
cfg := defaultConfig()
|
||||||
|
|
||||||
cfgData, marshalErr := yaml.Marshal(cfg)
|
if err := validateConfig(cfg); err != nil {
|
||||||
if marshalErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"failed to create default config data: %w",
|
"default config is invalid: %w",
|
||||||
marshalErr,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if writeErr := os.WriteFile(finalConfigPath, cfgData, 0600); writeErr != nil {
|
cfgData, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"failed to create default config: %w",
|
"failed to encode default config: %w",
|
||||||
writeErr,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Created default config at %s", finalConfigPath)
|
configDir := filepath.Dir(
|
||||||
log.Printf(
|
|
||||||
"WARNING: change the default web Basic Auth password in %s",
|
|
||||||
finalConfigPath,
|
finalConfigPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
resolveConfigPaths(cfg, exeDir)
|
if err := os.MkdirAll(
|
||||||
|
configDir,
|
||||||
if err := validateConfig(cfg); err != nil {
|
0755,
|
||||||
return nil, err
|
); 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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ВАЖНО:
|
||||||
|
|
||||||
|
Если config.yaml существует, НЕ используем defaultConfig()
|
||||||
|
как основу.
|
||||||
|
|
||||||
|
Иначе ошибочное/отсутствующее поле может незаметно получить
|
||||||
|
значение, зашитое в бинарник.
|
||||||
|
*/
|
||||||
var cfg Config
|
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(
|
return nil, fmt.Errorf(
|
||||||
"failed to parse YAML config %s: %w",
|
"failed to parse YAML config %s: %w",
|
||||||
finalConfigPath,
|
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
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -11,7 +11,14 @@ web:
|
|||||||
port: 8089
|
port: 8089
|
||||||
directory: web
|
directory: web
|
||||||
|
|
||||||
basic_auth:
|
auth:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
session_timeout: 15m
|
||||||
|
cookie_secure: false
|
||||||
|
|
||||||
username: admin
|
username: admin
|
||||||
password: admin
|
password: admin
|
||||||
|
|
||||||
|
login_max_attempts: 5
|
||||||
|
login_window: 5m
|
||||||
|
login_lockout: 15m
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ type Program struct {
|
|||||||
webServer *WebServer
|
webServer *WebServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (p *Program) Start(
|
func (p *Program) Start(
|
||||||
s service.Service,
|
s service.Service,
|
||||||
) error {
|
) error {
|
||||||
|
|||||||
+382
-129
@@ -1,56 +1,88 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
var iframeObservers = new WeakMap();
|
||||||
initializeEmailList();
|
|
||||||
});
|
document.addEventListener(
|
||||||
|
'DOMContentLoaded',
|
||||||
|
function () {
|
||||||
|
initializeEmailList();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
function initializeEmailList() {
|
function initializeEmailList() {
|
||||||
var emailItems = document.querySelectorAll('.email-item');
|
var emailItems =
|
||||||
|
document.querySelectorAll('.email-item');
|
||||||
|
|
||||||
emailItems.forEach(function (item) {
|
emailItems.forEach(function (item) {
|
||||||
var summary = item.querySelector('.email-summary');
|
var summary =
|
||||||
var downloadButton = item.querySelector('.download-icon');
|
item.querySelector('.email-summary');
|
||||||
|
|
||||||
|
var downloadButton =
|
||||||
|
item.querySelector('.download-icon');
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
summary.addEventListener('click', function (event) {
|
summary.addEventListener(
|
||||||
if (event.target.closest('.download-icon')) {
|
'click',
|
||||||
return;
|
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 (
|
||||||
if (
|
event.target.closest(
|
||||||
event.key !== 'Enter' &&
|
'.download-icon'
|
||||||
event.key !== ' '
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
toggleEmail(item);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (event.target.closest('.download-icon')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
toggleEmail(item);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadButton) {
|
if (downloadButton) {
|
||||||
downloadButton.addEventListener('click', function (event) {
|
downloadButton.addEventListener(
|
||||||
event.stopPropagation();
|
'click',
|
||||||
|
function (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
var id = item.dataset.id;
|
var id = item.dataset.id;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
console.error('Email ID is missing');
|
console.error(
|
||||||
return;
|
'Email ID is missing'
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadEmail(
|
||||||
|
event,
|
||||||
|
id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
downloadEmail(event, id);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -59,14 +91,26 @@ function toggleEmail(item) {
|
|||||||
var id = item.dataset.id;
|
var id = item.dataset.id;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
console.error('Email ID is missing');
|
console.error(
|
||||||
|
'Email ID is missing'
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var summary = item.querySelector('.email-summary');
|
var summary =
|
||||||
var detail = item.querySelector('.email-detail');
|
item.querySelector('.email-summary');
|
||||||
var toggleIcon = item.querySelector('.toggle');
|
|
||||||
var iframe = item.querySelector('.email-content-frame');
|
var detail =
|
||||||
|
item.querySelector('.email-detail');
|
||||||
|
|
||||||
|
var toggleIcon =
|
||||||
|
item.querySelector('.toggle');
|
||||||
|
|
||||||
|
var iframe =
|
||||||
|
item.querySelector(
|
||||||
|
'.email-content-frame'
|
||||||
|
);
|
||||||
|
|
||||||
if (!detail || !iframe) {
|
if (!detail || !iframe) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -77,13 +121,16 @@ function toggleEmail(item) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isHidden = detail.classList.contains('hidden');
|
var isHidden =
|
||||||
|
detail.classList.contains('hidden');
|
||||||
|
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
detail.classList.remove('hidden');
|
detail.classList.remove('hidden');
|
||||||
|
|
||||||
if (toggleIcon) {
|
if (toggleIcon) {
|
||||||
toggleIcon.classList.add('rotated');
|
toggleIcon.classList.add(
|
||||||
|
'rotated'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
@@ -101,7 +148,9 @@ function toggleEmail(item) {
|
|||||||
detail.classList.add('hidden');
|
detail.classList.add('hidden');
|
||||||
|
|
||||||
if (toggleIcon) {
|
if (toggleIcon) {
|
||||||
toggleIcon.classList.remove('rotated');
|
toggleIcon.classList.remove(
|
||||||
|
'rotated'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
@@ -114,11 +163,19 @@ function toggleEmail(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadEmailContent(id, iframe) {
|
function loadEmailContent(id, iframe) {
|
||||||
if (iframe.dataset.loaded === 'true') {
|
if (
|
||||||
|
iframe.dataset.loaded === 'true'
|
||||||
|
) {
|
||||||
resizeIframe(iframe);
|
resizeIframe(iframe);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
iframe.dataset.loading === 'true'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
iframe.dataset.loading = 'true';
|
iframe.dataset.loading = 'true';
|
||||||
|
|
||||||
fetch(
|
fetch(
|
||||||
@@ -130,6 +187,8 @@ function loadEmailContent(id, iframe) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
|
handleUnauthorized(response);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'HTTP error ' +
|
'HTTP error ' +
|
||||||
@@ -140,12 +199,15 @@ function loadEmailContent(id, iframe) {
|
|||||||
return response.text();
|
return response.text();
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
|
resetIframeObserver(iframe);
|
||||||
|
|
||||||
writeEmailToIframe(
|
writeEmailToIframe(
|
||||||
iframe,
|
iframe,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
|
|
||||||
iframe.dataset.loaded = 'true';
|
iframe.dataset.loaded =
|
||||||
|
'true';
|
||||||
|
|
||||||
delete iframe.dataset.loading;
|
delete iframe.dataset.loading;
|
||||||
|
|
||||||
@@ -154,6 +216,17 @@ function loadEmailContent(id, iframe) {
|
|||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
delete iframe.dataset.loading;
|
delete iframe.dataset.loading;
|
||||||
|
|
||||||
|
// При 401 уже выполняется redirect.
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
error.message ===
|
||||||
|
'Session expired'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetIframeObserver(iframe);
|
||||||
|
|
||||||
writeErrorToIframe(
|
writeErrorToIframe(
|
||||||
iframe,
|
iframe,
|
||||||
error.message
|
error.message
|
||||||
@@ -167,71 +240,94 @@ function loadEmailContent(id, iframe) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeEmailToIframe(iframe, emailBody) {
|
function handleUnauthorized(response) {
|
||||||
var doc = iframe.contentWindow.document;
|
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.open();
|
||||||
|
|
||||||
doc.write(
|
doc.write(
|
||||||
'<!DOCTYPE html>' +
|
'<!DOCTYPE html>' +
|
||||||
|
|
||||||
'<html>' +
|
'<html>' +
|
||||||
|
|
||||||
'<head>' +
|
'<head>' +
|
||||||
|
|
||||||
'<meta charset="UTF-8">' +
|
'<meta charset="UTF-8">' +
|
||||||
'<meta name="viewport" content="width=device-width, initial-scale=1.0">' +
|
|
||||||
|
'<meta ' +
|
||||||
|
'name="viewport" ' +
|
||||||
|
'content="width=device-width, initial-scale=1.0"' +
|
||||||
|
'>' +
|
||||||
|
|
||||||
'<base target="_blank">' +
|
'<base target="_blank">' +
|
||||||
|
|
||||||
'<style>' +
|
'<style>' +
|
||||||
|
|
||||||
'html {' +
|
'html {' +
|
||||||
'margin: 0 !important;' +
|
'margin:0 !important;' +
|
||||||
'padding: 0 !important;' +
|
'padding:0 !important;' +
|
||||||
'background: white !important;' +
|
'background:white !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'body {' +
|
'body {' +
|
||||||
'margin: 0 !important;' +
|
'margin:0 !important;' +
|
||||||
'padding: 15px !important;' +
|
'padding:15px !important;' +
|
||||||
'font-family: monospace !important;' +
|
'font-family:monospace !important;' +
|
||||||
'font-size: 12px !important;' +
|
'font-size:12px !important;' +
|
||||||
'line-height: 1.4 !important;' +
|
'line-height:1.4 !important;' +
|
||||||
'background: white !important;' +
|
'background:white !important;' +
|
||||||
'color: #333 !important;' +
|
'color:#333 !important;' +
|
||||||
'overflow-wrap: anywhere !important;' +
|
'overflow-wrap:anywhere !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'* {' +
|
'* {' +
|
||||||
'box-sizing: border-box !important;' +
|
'box-sizing:border-box !important;' +
|
||||||
'max-width: 100% !important;' +
|
'max-width:100% !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'img {' +
|
'img {' +
|
||||||
'max-width: 100% !important;' +
|
'max-width:100% !important;' +
|
||||||
'height: auto !important;' +
|
'height:auto !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'table {' +
|
'table {' +
|
||||||
'max-width: 100% !important;' +
|
'max-width:100% !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'pre {' +
|
'pre {' +
|
||||||
'margin: 0 !important;' +
|
'margin:0 !important;' +
|
||||||
'font-family: inherit !important;' +
|
'font-family:inherit !important;' +
|
||||||
'white-space: pre-wrap !important;' +
|
'white-space:pre-wrap !important;' +
|
||||||
'overflow-wrap: anywhere !important;' +
|
'overflow-wrap:anywhere !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'a {' +
|
'a {' +
|
||||||
'color: #2563eb !important;' +
|
'color:#2563eb !important;' +
|
||||||
'text-decoration: underline !important;' +
|
'text-decoration:underline !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'a:visited {' +
|
'a:visited {' +
|
||||||
'color: #7c3aed !important;' +
|
'color:#7c3aed !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'a:hover {' +
|
'a:hover {' +
|
||||||
'color: #1d4ed8 !important;' +
|
'color:#1d4ed8 !important;' +
|
||||||
'}' +
|
'}' +
|
||||||
|
|
||||||
'</style>' +
|
'</style>' +
|
||||||
@@ -239,7 +335,7 @@ function writeEmailToIframe(iframe, emailBody) {
|
|||||||
'</head>' +
|
'</head>' +
|
||||||
|
|
||||||
'<body>' +
|
'<body>' +
|
||||||
emailBody +
|
emailBody +
|
||||||
'</body>' +
|
'</body>' +
|
||||||
|
|
||||||
'</html>'
|
'</html>'
|
||||||
@@ -248,26 +344,36 @@ function writeEmailToIframe(iframe, emailBody) {
|
|||||||
doc.close();
|
doc.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeErrorToIframe(iframe, message) {
|
function writeErrorToIframe(
|
||||||
var doc = iframe.contentWindow.document;
|
iframe,
|
||||||
|
message
|
||||||
|
) {
|
||||||
|
var doc =
|
||||||
|
iframe.contentWindow.document;
|
||||||
|
|
||||||
doc.open();
|
doc.open();
|
||||||
|
|
||||||
doc.write(
|
doc.write(
|
||||||
'<!DOCTYPE html>' +
|
'<!DOCTYPE html>' +
|
||||||
|
|
||||||
'<html>' +
|
'<html>' +
|
||||||
|
|
||||||
'<head>' +
|
'<head>' +
|
||||||
'<meta charset="UTF-8">' +
|
'<meta charset="UTF-8">' +
|
||||||
'</head>' +
|
'</head>' +
|
||||||
|
|
||||||
'<body style="' +
|
'<body style="' +
|
||||||
'padding:15px;' +
|
'padding:15px;' +
|
||||||
'font-family:monospace;' +
|
'font-family:monospace;' +
|
||||||
'font-size:12px;' +
|
'font-size:12px;' +
|
||||||
'color:#b91c1c;' +
|
'color:#b91c1c;' +
|
||||||
'">' +
|
'">' +
|
||||||
|
|
||||||
'Error loading email: ' +
|
'Error loading email: ' +
|
||||||
escapeHTML(message) +
|
escapeHTML(message) +
|
||||||
|
|
||||||
'</body>' +
|
'</body>' +
|
||||||
|
|
||||||
'</html>'
|
'</html>'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -276,68 +382,81 @@ function writeErrorToIframe(iframe, message) {
|
|||||||
resizeIframe(iframe);
|
resizeIframe(iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetIframeObserver(iframe) {
|
||||||
|
var observer =
|
||||||
|
iframeObservers.get(iframe);
|
||||||
|
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
|
||||||
|
iframeObservers.delete(
|
||||||
|
iframe
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resizeIframe(iframe) {
|
function resizeIframe(iframe) {
|
||||||
if (!iframe || !iframe.contentWindow) {
|
if (
|
||||||
|
!iframe ||
|
||||||
|
!iframe.contentWindow
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var doc = iframe.contentWindow.document;
|
var doc =
|
||||||
|
iframe.contentWindow.document;
|
||||||
|
|
||||||
if (!doc || !doc.body) {
|
if (!doc || !doc.body) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var height = Math.max(
|
updateIframeHeight(
|
||||||
doc.body.scrollHeight,
|
iframe,
|
||||||
doc.documentElement
|
doc
|
||||||
? doc.documentElement.scrollHeight
|
|
||||||
: 0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
iframe.style.height =
|
|
||||||
Math.max(height + 20, 200) +
|
|
||||||
'px';
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!iframe.dataset.resizeObserver &&
|
typeof ResizeObserver ===
|
||||||
typeof ResizeObserver !== 'undefined'
|
'undefined'
|
||||||
) {
|
) {
|
||||||
var observer = new ResizeObserver(
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing =
|
||||||
|
iframeObservers.get(iframe);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var observer =
|
||||||
|
new ResizeObserver(
|
||||||
function () {
|
function () {
|
||||||
try {
|
try {
|
||||||
var body =
|
var currentDoc =
|
||||||
iframe.contentWindow.document.body;
|
iframe
|
||||||
|
.contentWindow
|
||||||
|
.document;
|
||||||
|
|
||||||
var html =
|
updateIframeHeight(
|
||||||
iframe.contentWindow.document.documentElement;
|
iframe,
|
||||||
|
currentDoc
|
||||||
var newHeight = Math.max(
|
|
||||||
body
|
|
||||||
? body.scrollHeight
|
|
||||||
: 0,
|
|
||||||
html
|
|
||||||
? html.scrollHeight
|
|
||||||
: 0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
iframe.style.height =
|
|
||||||
Math.max(
|
|
||||||
newHeight + 20,
|
|
||||||
200
|
|
||||||
) +
|
|
||||||
'px';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Iframe may have been replaced.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
observer.observe(doc.body);
|
observer.observe(
|
||||||
|
doc.body
|
||||||
|
);
|
||||||
|
|
||||||
iframe.dataset.resizeObserver =
|
iframeObservers.set(
|
||||||
'true';
|
iframe,
|
||||||
}
|
observer
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Cannot resize email iframe:',
|
'Cannot resize email iframe:',
|
||||||
@@ -346,39 +465,173 @@ function resizeIframe(iframe) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadEmail(event, id) {
|
function updateIframeHeight(
|
||||||
var icon = event.currentTarget;
|
iframe,
|
||||||
|
doc
|
||||||
|
) {
|
||||||
|
if (!doc || !doc.body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyHeight =
|
||||||
|
doc.body.scrollHeight;
|
||||||
|
|
||||||
|
var htmlHeight =
|
||||||
|
doc.documentElement
|
||||||
|
? doc.documentElement.scrollHeight
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var height = Math.max(
|
||||||
|
bodyHeight,
|
||||||
|
htmlHeight,
|
||||||
|
180
|
||||||
|
);
|
||||||
|
|
||||||
|
iframe.style.height =
|
||||||
|
height + 20 + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadEmail(
|
||||||
|
event,
|
||||||
|
id
|
||||||
|
) {
|
||||||
|
var button =
|
||||||
|
event.currentTarget;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var originalTransform =
|
var originalTransform =
|
||||||
icon.style.transform;
|
button.style.transform;
|
||||||
|
|
||||||
var originalOpacity =
|
var originalOpacity =
|
||||||
icon.style.opacity;
|
button.style.opacity;
|
||||||
|
|
||||||
icon.style.transform =
|
button.disabled = true;
|
||||||
|
|
||||||
|
button.style.transform =
|
||||||
'scale(0.8)';
|
'scale(0.8)';
|
||||||
|
|
||||||
icon.style.opacity =
|
button.style.opacity =
|
||||||
'0.5';
|
'0.5';
|
||||||
|
|
||||||
window.location.href =
|
fetch(
|
||||||
'/api/download?id=' +
|
'/api/download?id=' +
|
||||||
encodeURIComponent(id);
|
encodeURIComponent(id),
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
handleUnauthorized(response);
|
||||||
|
|
||||||
window.setTimeout(
|
if (!response.ok) {
|
||||||
function () {
|
throw new Error(
|
||||||
icon.style.transform =
|
'HTTP error ' +
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
response.blob(),
|
||||||
|
Promise.resolve(
|
||||||
|
getDownloadFilename(
|
||||||
|
response,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.then(function (result) {
|
||||||
|
var blob = result[0];
|
||||||
|
var filename = result[1];
|
||||||
|
|
||||||
|
var objectURL =
|
||||||
|
URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
var link =
|
||||||
|
document.createElement('a');
|
||||||
|
|
||||||
|
link.href = objectURL;
|
||||||
|
link.download = filename;
|
||||||
|
|
||||||
|
document.body.appendChild(
|
||||||
|
link
|
||||||
|
);
|
||||||
|
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
window.setTimeout(
|
||||||
|
function () {
|
||||||
|
URL.revokeObjectURL(
|
||||||
|
objectURL
|
||||||
|
);
|
||||||
|
},
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
error.message ===
|
||||||
|
'Session expired'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
'Failed to download email:',
|
||||||
|
id,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
window.alert(
|
||||||
|
'Failed to download email'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
button.disabled = false;
|
||||||
|
|
||||||
|
button.style.transform =
|
||||||
originalTransform;
|
originalTransform;
|
||||||
|
|
||||||
icon.style.opacity =
|
button.style.opacity =
|
||||||
originalOpacity;
|
originalOpacity;
|
||||||
},
|
});
|
||||||
150
|
}
|
||||||
);
|
|
||||||
|
function getDownloadFilename(
|
||||||
|
response,
|
||||||
|
id
|
||||||
|
) {
|
||||||
|
var contentDisposition =
|
||||||
|
response.headers.get(
|
||||||
|
'Content-Disposition'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
var match =
|
||||||
|
contentDisposition.match(
|
||||||
|
/filename="([^"]+)"/i
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = id.split('/');
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return parts[
|
||||||
|
parts.length - 1
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'email.eml';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHTML(value) {
|
function escapeHTML(value) {
|
||||||
|
|||||||
+40
-13
@@ -19,28 +19,49 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
|
|
||||||
<div class="max-w-6xl mx-auto bg-white min-h-screen shadow-sm">
|
<div class="max-w-6xl mx-auto bg-white min-h-screen shadow-sm">
|
||||||
|
|
||||||
<!-- Header -->
|
<header class="bg-gray-800 text-white px-6 py-4 border-b-4 border-red-600 flex justify-between items-center">
|
||||||
<header class="bg-gray-800 text-white px-6 py-4 border-b-4 border-red-600">
|
|
||||||
<h1 class="text-2xl font-normal">
|
<div>
|
||||||
Fake SMTP Server
|
<h1 class="text-2xl font-normal">
|
||||||
</h1>
|
Fake SMTP Server
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-gray-400 text-sm mt-1">
|
||||||
|
Email storage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/logout"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="csrf_token"
|
||||||
|
value="{{.CSRFToken}}"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-sm text-gray-300 hover:text-white px-3 py-2 border border-gray-600 hover:border-gray-400"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p class="text-gray-400 text-sm mt-1">
|
|
||||||
Email storage
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<main class="p-4">
|
<main class="p-4">
|
||||||
|
|
||||||
{{range .Days}}
|
{{range .Days}}
|
||||||
|
|
||||||
<section class="mb-6 border border-gray-200">
|
<section class="mb-6 border border-gray-200">
|
||||||
|
|
||||||
<!-- Day header -->
|
|
||||||
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
|
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
|
||||||
<h2 class="text-base font-semibold text-gray-700">
|
<h2 class="text-base font-semibold text-gray-700">
|
||||||
{{.Date}}
|
{{.Date}}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -48,9 +69,9 @@
|
|||||||
<span class="bg-gray-400 text-white px-2 py-0.5 text-xs">
|
<span class="bg-gray-400 text-white px-2 py-0.5 text-xs">
|
||||||
{{len .Emails}} emails
|
{{len .Emails}} emails
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Emails -->
|
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
|
|
||||||
{{range .Emails}}
|
{{range .Emails}}
|
||||||
@@ -60,13 +81,13 @@
|
|||||||
data-id="{{.ID}}"
|
data-id="{{.ID}}"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- Email summary -->
|
|
||||||
<div
|
<div
|
||||||
class="email-summary px-4 py-2 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
|
class="email-summary px-4 py-2 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-wrap gap-4 items-baseline">
|
<div class="flex-1 flex flex-wrap gap-4 items-baseline">
|
||||||
|
|
||||||
<div class="font-mono text-sm font-semibold text-gray-700 min-w-[180px]">
|
<div class="font-mono text-sm font-semibold text-gray-700 min-w-[180px]">
|
||||||
@@ -110,20 +131,23 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email detail -->
|
|
||||||
<div class="email-detail hidden bg-gray-50 border-t border-gray-200 p-4">
|
<div class="email-detail hidden bg-gray-50 border-t border-gray-200 p-4">
|
||||||
|
|
||||||
<div class="email-content-wrapper">
|
<div class="email-content-wrapper">
|
||||||
|
|
||||||
<iframe
|
<iframe
|
||||||
class="email-content-frame"
|
class="email-content-frame"
|
||||||
title="Email content"
|
title="Email content"
|
||||||
sandbox="allow-same-origin allow-popups"
|
sandbox="allow-same-origin allow-popups"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
srcdoc="<html><body style='margin:0;padding:15px;font-family:monospace;font-size:12px;'>Loading...</body></html>"
|
srcdoc="<html><body style='margin:0;padding:15px;font-family:monospace;font-size:12px;'>Loading...</body></html>"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
@@ -131,6 +155,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -154,8 +179,10 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
>
|
||||||
|
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
|
||||||
|
<script src="/static/tailwind.js"></script>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/static/app.css"
|
||||||
|
>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
|
||||||
|
<main class="w-full max-w-sm bg-white shadow border border-gray-200">
|
||||||
|
|
||||||
|
<header class="bg-gray-800 text-white px-6 py-4 border-b-4 border-red-600">
|
||||||
|
<h1 class="text-xl font-normal">
|
||||||
|
Fake SMTP Server
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-gray-400 text-sm mt-1">
|
||||||
|
Sign in
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
|
||||||
|
<div class="mb-4 px-3 py-2 bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/login"
|
||||||
|
autocomplete="on"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="username"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="username"
|
||||||
|
class="w-full border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:border-gray-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<label
|
||||||
|
for="password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:border-gray-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1608
-297
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user