From 5471f3b138686c298b6fb3de6b4fb68d62db53c4 Mon Sep 17 00:00:00 2001 From: Yigid BALABAN Date: Tue, 14 Oct 2025 21:20:25 +0300 Subject: [PATCH] initial commit --- .cursor/rules/project-details.mdc | 13 ++ .dockerignore | 11 ++ .env.development | 25 ++++ .env.example | 27 ++++ .gitignore | 12 ++ Dockerfile | 30 +++++ cmd/deployer/main.go | 90 +++++++++++++ docker-compose.yml | 14 ++ go.mod | 18 +++ go.sum | 49 +++++++ internal/config/config.go | 112 ++++++++++++++++ internal/db/db.go | 63 +++++++++ internal/deploy/manager.go | 215 ++++++++++++++++++++++++++++++ internal/http/handlers.go | 210 +++++++++++++++++++++++++++++ internal/http/middleware.go | 138 +++++++++++++++++++ internal/http/router.go | 29 ++++ internal/logging/logger.go | 29 ++++ internal/user/manager.go | 182 +++++++++++++++++++++++++ test-deployment.sh | 98 ++++++++++++++ 19 files changed, 1365 insertions(+) create mode 100644 .cursor/rules/project-details.mdc create mode 100644 .dockerignore create mode 100644 .env.development create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/deployer/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/deploy/manager.go create mode 100644 internal/http/handlers.go create mode 100644 internal/http/middleware.go create mode 100644 internal/http/router.go create mode 100644 internal/logging/logger.go create mode 100644 internal/user/manager.go create mode 100755 test-deployment.sh diff --git a/.cursor/rules/project-details.mdc b/.cursor/rules/project-details.mdc new file mode 100644 index 0000000..3908436 --- /dev/null +++ b/.cursor/rules/project-details.mdc @@ -0,0 +1,13 @@ +--- +alwaysApply: true +--- +We are building a Go-based HTTP service. The service runs in a Docker container, only accesses the host through mounted volumes. See the "PRD.md" file in the project root for more details. + +Tech stack: Go, Docker, Docker Compose, SQLite + +Commands: +- use `docker compose` instead of `docker-compose` + +Dependencies/versioning: +- Built on golang `1.25.3+` +- SQLite driver: `modernc.org/sqlite` \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4fc11a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +*.md +data/ +docs/ +deploys/ +.env +.env.example +docker-compose.yml +Makefile + diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..fae3c3b --- /dev/null +++ b/.env.development @@ -0,0 +1,25 @@ +# Required: Admin bearer token for API access +ADMIN_TOKEN=devadmintoken + +# Docker host binding (used by docker-compose for port mapping) +HOST=0.0.0.0 +PORT=8080 + +# Optional: Service configuration (defaults shown) +DEPLOY_ROOT=/var/www/docs +RELEASE_ROOT=/var/www/deploys +DB_PATH=/data/deployer.db + +# Optional: Upload and retention settings +MAX_UPLOAD_SIZE=104857600 +RELEASES_TO_KEEP=5 + +# Optional: Logging +LOG_LEVEL=info + +# Optional: File deletion behavior +DISABLE_FILE_DELETE_ON_USER_REMOVE=false + +# Optional: TLS configuration (if terminating TLS in the service) +# TLS_CERT=/path/to/cert.pem +# TLS_KEY=/path/to/key.pem diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8948f67 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Required: Admin bearer token for API access +ADMIN_TOKEN=your-secure-admin-token-here + +# Docker host binding (used by docker-compose for port mapping) +# The Go service always binds to 0.0.0.0:8080 inside the container +HOST=0.0.0.0 +PORT=8080 + +# Optional: Service configuration (defaults shown) +DEPLOY_ROOT=/var/www/docs +RELEASE_ROOT=/var/www/deploys +DB_PATH=/data/deployer.db + +# Optional: Upload and retention settings +MAX_UPLOAD_SIZE=104857600 +RELEASES_TO_KEEP=5 + +# Optional: Logging +LOG_LEVEL=info + +# Optional: File deletion behavior +DISABLE_FILE_DELETE_ON_USER_REMOVE=false + +# Optional: TLS configuration (if terminating TLS in the service) +# TLS_CERT=/path/to/cert.pem +# TLS_KEY=/path/to/key.pem + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a07d543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +bin/ +data/ +docs/ +deploys/ +.env* +!.env.example +!.env.development +*.db +*.db-wal +*.db-shm +*.tar.gz + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11fffc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.25.3-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ + -ldflags="-s -w" \ + -o /bin/deployer \ + ./cmd/deployer + +FROM alpine:latest + +RUN adduser -D -u 1000 deployer && \ + chown -R deployer:deployer /tmp + +COPY --from=builder /bin/deployer /deployer + +USER deployer + +EXPOSE 8080 + +ENTRYPOINT ["/deployer"] + diff --git a/cmd/deployer/main.go b/cmd/deployer/main.go new file mode 100644 index 0000000..1c14bcf --- /dev/null +++ b/cmd/deployer/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.yigid.dev/fyb/tingz/internal/config" + "git.yigid.dev/fyb/tingz/internal/db" + "git.yigid.dev/fyb/tingz/internal/deploy" + httpserver "git.yigid.dev/fyb/tingz/internal/http" + "git.yigid.dev/fyb/tingz/internal/logging" + "git.yigid.dev/fyb/tingz/internal/user" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + logger := logging.New(cfg.LogLevel) + logger.Info("deployer service starting", + "log_level", cfg.LogLevel) + + database, err := db.OpenDB(cfg.DBPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer database.Close() + + if err := db.EnsureSchema(database); err != nil { + return fmt.Errorf("failed to ensure schema: %w", err) + } + + userMgr := user.NewManager(database, cfg.DeployRoot, cfg.ReleaseRoot) + deployMgr := deploy.NewManager(cfg.DeployRoot, cfg.ReleaseRoot, cfg.ReleasesToKeep, logger) + + router := httpserver.NewRouter(cfg, userMgr, deployMgr, logger) + + server := &http.Server{ + Addr: "0.0.0.0:8080", + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + errChan := make(chan error, 1) + go func() { + logger.Info("server listening", "addr", server.Addr) + if cfg.TLSCert != "" && cfg.TLSKey != "" { + errChan <- server.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey) + } else { + errChan <- server.ListenAndServe() + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-errChan: + return fmt.Errorf("server error: %w", err) + case sig := <-sigChan: + logger.Info("received shutdown signal", "signal", sig) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + return fmt.Errorf("server shutdown failed: %w", err) + } + + logger.Info("server stopped gracefully") + return nil +} + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9c82c1c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + server: + build: . + container_name: tingz-server + restart: unless-stopped + env_file: .env.development + volumes: + - ./data:/data + - ./docs:/var/www/docs + - ./deploys:/var/www/deploys + ports: + - "${HOST}:${PORT}:8080" + user: "1000:1000" + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1048ea2 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module git.yigid.dev/fyb/tingz + +go 1.24.3 + +require modernc.org/sqlite v1.39.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4ffbac8 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8f9d739 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,112 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +type Config struct { + AdminToken string + DeployRoot string + ReleaseRoot string + DBPath string + MaxUploadSize int64 + LogLevel string + ReleasesToKeep int + DisableFileDeleteOnUserRemove bool + TLSCert string + TLSKey string +} + +func LoadConfig() (*Config, error) { + cfg := &Config{ + AdminToken: os.Getenv("ADMIN_TOKEN"), + DeployRoot: getEnvOrDefault("DEPLOY_ROOT", "/var/www/docs"), + ReleaseRoot: getEnvOrDefault("RELEASE_ROOT", "/var/www/deploys"), + DBPath: getEnvOrDefault("DB_PATH", "/data/deployer.db"), + MaxUploadSize: getEnvAsInt64("MAX_UPLOAD_SIZE", 104857600), + LogLevel: getEnvOrDefault("LOG_LEVEL", "info"), + ReleasesToKeep: getEnvAsInt("RELEASES_TO_KEEP", 5), + DisableFileDeleteOnUserRemove: getEnvAsBool("DISABLE_FILE_DELETE_ON_USER_REMOVE", false), + TLSCert: os.Getenv("TLS_CERT"), + TLSKey: os.Getenv("TLS_KEY"), + } + + if err := cfg.validate(); err != nil { + return nil, err + } + + return cfg, nil +} + +func (c *Config) validate() error { + if c.AdminToken == "" { + return fmt.Errorf("ADMIN_TOKEN is required") + } + + validLogLevels := map[string]bool{ + "debug": true, + "info": true, + "warn": true, + "error": true, + } + + if !validLogLevels[c.LogLevel] { + return fmt.Errorf("invalid LOG_LEVEL: %s (must be debug, info, warn, or error)", c.LogLevel) + } + + if c.MaxUploadSize <= 0 { + return fmt.Errorf("MAX_UPLOAD_SIZE must be positive") + } + + if c.ReleasesToKeep < 1 { + return fmt.Errorf("RELEASES_TO_KEEP must be at least 1") + } + + return nil +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvAsInt(key string, defaultValue int) int { + valueStr := os.Getenv(key) + if valueStr == "" { + return defaultValue + } + value, err := strconv.Atoi(valueStr) + if err != nil { + return defaultValue + } + return value +} + +func getEnvAsInt64(key string, defaultValue int64) int64 { + valueStr := os.Getenv(key) + if valueStr == "" { + return defaultValue + } + value, err := strconv.ParseInt(valueStr, 10, 64) + if err != nil { + return defaultValue + } + return value +} + +func getEnvAsBool(key string, defaultValue bool) bool { + valueStr := os.Getenv(key) + if valueStr == "" { + return defaultValue + } + value, err := strconv.ParseBool(valueStr) + if err != nil { + return defaultValue + } + return value +} + diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..8553625 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,63 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func OpenDB(path string) (*sql.DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA foreign_keys=ON", + "PRAGMA synchronous=NORMAL", + "PRAGMA busy_timeout=5000", + } + + for _, pragma := range pragmas { + if _, err := db.Exec(pragma); err != nil { + db.Close() + return nil, fmt.Errorf("failed to set pragma: %w", err) + } + } + + return db, nil +} + +func EnsureSchema(db *sql.DB) error { + schema := ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + token TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_users_token ON users(token); + ` + + if _, err := db.Exec(schema); err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + + return nil +} + diff --git a/internal/deploy/manager.go b/internal/deploy/manager.go new file mode 100644 index 0000000..54a5a5d --- /dev/null +++ b/internal/deploy/manager.go @@ -0,0 +1,215 @@ +package deploy + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +type Manager struct { + deployRoot string + releaseRoot string + releasesToKeep int + logger *slog.Logger +} + +func NewManager(deployRoot, releaseRoot string, releasesToKeep int, logger *slog.Logger) *Manager { + return &Manager{ + deployRoot: deployRoot, + releaseRoot: releaseRoot, + releasesToKeep: releasesToKeep, + logger: logger, + } +} + +func (m *Manager) Deploy(ctx context.Context, username, project string, r io.Reader) (string, error) { + releaseID := time.Now().UTC().Format("20060102T150405") + + m.logger.Info("starting deployment", + slog.String("username", username), + slog.String("project", project), + slog.String("release_id", releaseID)) + + tmpFile, err := os.CreateTemp("", fmt.Sprintf("deployer-upload-%s-%s-*.tar.gz", username, releaseID)) + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + written, err := io.Copy(tmpFile, r) + if err != nil { + return "", fmt.Errorf("failed to write tarball: %w", err) + } + + m.logger.Debug("tarball saved to temp file", + slog.String("path", tmpFile.Name()), + slog.Int64("bytes", written)) + + if _, err := tmpFile.Seek(0, 0); err != nil { + return "", fmt.Errorf("failed to seek temp file: %w", err) + } + + releasePath := filepath.Join(m.releaseRoot, username, project, releaseID) + if err := os.MkdirAll(releasePath, 0755); err != nil { + return "", fmt.Errorf("failed to create release directory: %w", err) + } + + if err := m.extractTarball(tmpFile, releasePath); err != nil { + os.RemoveAll(releasePath) + return "", fmt.Errorf("failed to extract tarball: %w", err) + } + + deployPath := filepath.Join(m.deployRoot, username, project) + deployParentDir := filepath.Dir(deployPath) + if err := os.MkdirAll(deployParentDir, 0755); err != nil { + return "", fmt.Errorf("failed to create deploy parent directory: %w", err) + } + + tmpLink := deployPath + ".tmp." + releaseID + if err := os.Symlink(releasePath, tmpLink); err != nil { + return "", fmt.Errorf("failed to create temporary symlink: %w", err) + } + + if err := os.Rename(tmpLink, deployPath); err != nil { + os.Remove(tmpLink) + return "", fmt.Errorf("failed to swap symlink: %w", err) + } + + m.logger.Info("deployment successful", + slog.String("username", username), + slog.String("project", project), + slog.String("release_id", releaseID)) + + if err := m.cleanupOldReleases(username, project); err != nil { + m.logger.Warn("failed to cleanup old releases", + slog.String("error", err.Error())) + } + + return releaseID, nil +} + +func (m *Manager) extractTarball(r io.Reader, destDir string) error { + gzr, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + + if err := validateTarPath(header.Name); err != nil { + return err + } + + cleanName := filepath.Clean(header.Name) + if cleanName == "." || cleanName == ".." { + continue + } + + target := filepath.Join(destDir, cleanName) + + if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("path traversal detected: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return fmt.Errorf("failed to write file: %w", err) + } + f.Close() + } + } + + return nil +} + +func validateTarPath(path string) error { + clean := filepath.Clean(path) + + parts := strings.Split(clean, string(filepath.Separator)) + for _, part := range parts { + if part == ".." { + return fmt.Errorf("path contains '..' component which is not allowed: %s", path) + } + } + + if filepath.IsAbs(path) { + return fmt.Errorf("absolute paths not allowed: %s", path) + } + + return nil +} + +func (m *Manager) cleanupOldReleases(username, project string) error { + releasesDir := filepath.Join(m.releaseRoot, username, project) + + entries, err := os.ReadDir(releasesDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read releases directory: %w", err) + } + + var releases []os.DirEntry + for _, entry := range entries { + if entry.IsDir() { + releases = append(releases, entry) + } + } + + if len(releases) <= m.releasesToKeep { + return nil + } + + sort.Slice(releases, func(i, j int) bool { + return releases[i].Name() > releases[j].Name() + }) + + for i := m.releasesToKeep; i < len(releases); i++ { + oldReleasePath := filepath.Join(releasesDir, releases[i].Name()) + m.logger.Info("removing old release", + slog.String("path", oldReleasePath)) + if err := os.RemoveAll(oldReleasePath); err != nil { + m.logger.Warn("failed to remove old release", + slog.String("path", oldReleasePath), + slog.String("error", err.Error())) + } + } + + return nil +} + diff --git a/internal/http/handlers.go b/internal/http/handlers.go new file mode 100644 index 0000000..ecfcb19 --- /dev/null +++ b/internal/http/handlers.go @@ -0,0 +1,210 @@ +package http + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "git.yigid.dev/fyb/tingz/internal/deploy" + "git.yigid.dev/fyb/tingz/internal/user" +) + +type Server struct { + userMgr *user.Manager + deployMgr *deploy.Manager + logger *slog.Logger + baseURL string +} + +func NewServer(userMgr *user.Manager, deployMgr *deploy.Manager, logger *slog.Logger, baseURL string) *Server { + return &Server{ + userMgr: userMgr, + deployMgr: deployMgr, + logger: logger, + baseURL: baseURL, + } +} + +func (s *Server) handleAuthCreate(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.logger.Warn("failed to decode request", slog.String("error", err.Error())) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "invalid request body", + }) + return + } + + if !user.ValidateUsername(req.Username) { + s.logger.Warn("invalid username", slog.String("username", req.Username)) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "invalid username format", + }) + return + } + + token, err := s.userMgr.CreateOrUpdate(r.Context(), req.Username) + if err != nil { + s.logger.Error("failed to create/update user", + slog.String("username", req.Username), + slog.String("error", err.Error())) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "status": "error", + "error": "failed to create user", + }) + return + } + + s.logger.Info("user created/updated", slog.String("username", req.Username)) + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "token": token, + }) +} + +func (s *Server) handleAuthDelete(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Files string `json:"files"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.logger.Warn("failed to decode request", slog.String("error", err.Error())) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "invalid request body", + }) + return + } + + if !user.ValidateUsername(req.Username) { + s.logger.Warn("invalid username", slog.String("username", req.Username)) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "invalid username format", + }) + return + } + + if req.Files == "" { + req.Files = "persist" + } + + deleteFiles := false + if req.Files == "delete" { + deleteFiles = true + } else if req.Files != "persist" { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "files must be 'persist' or 'delete'", + }) + return + } + + err := s.userMgr.Delete(r.Context(), req.Username, deleteFiles) + if err != nil { + s.logger.Error("failed to delete user", + slog.String("username", req.Username), + slog.String("error", err.Error())) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "status": "error", + "error": err.Error(), + }) + return + } + + s.logger.Info("user deleted", + slog.String("username", req.Username), + slog.Bool("files_deleted", deleteFiles)) + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + }) +} + +func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { + username, ok := getUsernameFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "status": "error", + "error": "unauthorized", + }) + return + } + + if err := r.ParseMultipartForm(32 << 20); err != nil { + s.logger.Warn("failed to parse multipart form", slog.String("error", err.Error())) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "failed to parse multipart form", + }) + return + } + + project := r.FormValue("project") + if !user.ValidateProjectName(project) { + s.logger.Warn("invalid project name", slog.String("project", project)) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "invalid project name format", + }) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + s.logger.Warn("failed to get file from form", slog.String("error", err.Error())) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "status": "error", + "error": "missing or invalid file", + }) + return + } + defer file.Close() + + s.logger.Info("processing deployment", + slog.String("username", username), + slog.String("project", project), + slog.String("filename", header.Filename), + slog.Int64("size", header.Size)) + + releaseID, err := s.deployMgr.Deploy(r.Context(), username, project, file) + if err != nil { + s.logger.Error("deployment failed", + slog.String("username", username), + slog.String("project", project), + slog.String("error", err.Error())) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "status": "error", + "error": err.Error(), + }) + return + } + + url := fmt.Sprintf("%s/%s/%s/", s.baseURL, username, project) + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "project": project, + "username": username, + "url": url, + "release": releaseID, + }) +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + }) +} + +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..e97f748 --- /dev/null +++ b/internal/http/middleware.go @@ -0,0 +1,138 @@ +package http + +import ( + "context" + "crypto/subtle" + "log/slog" + "net/http" + "strings" + "time" + + "git.yigid.dev/fyb/tingz/internal/user" +) + +type contextKey string + +const usernameKey contextKey = "username" + +func AdminAuthMiddleware(adminToken string, logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + logger.Warn("missing authorization header") + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "status": "error", + "error": "missing authorization header", + }) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + logger.Warn("invalid authorization header format") + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "status": "error", + "error": "invalid authorization header format", + }) + return + } + + token := parts[1] + if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) != 1 { + logger.Warn("invalid admin token") + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "status": "error", + "error": "invalid admin token", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func UserAuthMiddleware(userMgr *user.Manager, logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + logger.Warn("missing authorization header") + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "status": "error", + "error": "missing authorization header", + }) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + logger.Warn("invalid authorization header format") + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "status": "error", + "error": "invalid authorization header format", + }) + return + } + + token := parts[1] + username, err := userMgr.GetByToken(r.Context(), token) + if err != nil { + logger.Warn("invalid user token", slog.String("error", err.Error())) + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "status": "error", + "error": "invalid token", + }) + return + } + + ctx := context.WithValue(r.Context(), usernameKey, username) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(wrapped, r) + + duration := time.Since(start) + logger.Info("request completed", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.Int("status", wrapped.statusCode), + slog.Duration("duration", duration), + slog.String("remote_addr", r.RemoteAddr)) + }) + } +} + +func MaxBytesMiddleware(maxBytes int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + next.ServeHTTP(w, r) + }) + } +} + +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func getUsernameFromContext(ctx context.Context) (string, bool) { + username, ok := ctx.Value(usernameKey).(string) + return username, ok +} + diff --git a/internal/http/router.go b/internal/http/router.go new file mode 100644 index 0000000..9586daf --- /dev/null +++ b/internal/http/router.go @@ -0,0 +1,29 @@ +package http + +import ( + "log/slog" + "net/http" + + "git.yigid.dev/fyb/tingz/internal/config" + "git.yigid.dev/fyb/tingz/internal/deploy" + "git.yigid.dev/fyb/tingz/internal/user" +) + +func NewRouter(cfg *config.Config, userMgr *user.Manager, deployMgr *deploy.Manager, logger *slog.Logger) http.Handler { + mux := http.NewServeMux() + + baseURL := "https://docs.yigid.dev" + server := NewServer(userMgr, deployMgr, logger, baseURL) + + adminAuth := AdminAuthMiddleware(cfg.AdminToken, logger) + userAuth := UserAuthMiddleware(userMgr, logger) + maxBytes := MaxBytesMiddleware(cfg.MaxUploadSize) + + mux.Handle("POST /api/v1/auth", adminAuth(http.HandlerFunc(server.handleAuthCreate))) + mux.Handle("DELETE /api/v1/auth", adminAuth(http.HandlerFunc(server.handleAuthDelete))) + mux.Handle("POST /api/v1/deploy", userAuth(maxBytes(http.HandlerFunc(server.handleDeploy)))) + mux.HandleFunc("GET /api/v1/status", server.handleStatus) + + return LoggingMiddleware(logger)(mux) +} + diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..f9567d6 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,29 @@ +package logging + +import ( + "log/slog" + "os" +) + +func New(level string) *slog.Logger { + var logLevel slog.Level + switch level { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + }) + + return slog.New(handler) +} + diff --git a/internal/user/manager.go b/internal/user/manager.go new file mode 100644 index 0000000..ca19963 --- /dev/null +++ b/internal/user/manager.go @@ -0,0 +1,182 @@ +package user + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var usernameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$`) + +type Manager struct { + db *sql.DB + deployRoot string + releaseRoot string +} + +func NewManager(db *sql.DB, deployRoot, releaseRoot string) *Manager { + return &Manager{ + db: db, + deployRoot: deployRoot, + releaseRoot: releaseRoot, + } +} + +func (m *Manager) CreateOrUpdate(ctx context.Context, username string) (string, error) { + if !ValidateUsername(username) { + return "", fmt.Errorf("invalid username format") + } + + token, err := generateToken() + if err != nil { + return "", fmt.Errorf("failed to generate token: %w", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + + var existingID int + err = m.db.QueryRowContext(ctx, "SELECT id FROM users WHERE username = ?", username).Scan(&existingID) + + if err == sql.ErrNoRows { + _, err = m.db.ExecContext(ctx, + "INSERT INTO users (username, token, created_at, updated_at) VALUES (?, ?, ?, ?)", + username, token, now, now) + if err != nil { + return "", fmt.Errorf("failed to create user: %w", err) + } + } else if err == nil { + _, err = m.db.ExecContext(ctx, + "UPDATE users SET token = ?, updated_at = ? WHERE username = ?", + token, now, username) + if err != nil { + return "", fmt.Errorf("failed to update user: %w", err) + } + } else { + return "", fmt.Errorf("failed to check user existence: %w", err) + } + + if err := m.ensureUserDirectories(username); err != nil { + return "", fmt.Errorf("failed to create user directories: %w", err) + } + + return token, nil +} + +func (m *Manager) Delete(ctx context.Context, username string, deleteFiles bool) error { + if !ValidateUsername(username) { + return fmt.Errorf("invalid username format") + } + + result, err := m.db.ExecContext(ctx, "DELETE FROM users WHERE username = ?", username) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check deletion: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("user not found") + } + + if deleteFiles { + deployPath := filepath.Join(m.deployRoot, username) + releasePath := filepath.Join(m.releaseRoot, username) + + if err := os.RemoveAll(deployPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove deploy directory: %w", err) + } + + if err := os.RemoveAll(releasePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove release directory: %w", err) + } + } + + return nil +} + +func (m *Manager) GetByToken(ctx context.Context, token string) (string, error) { + var username string + err := m.db.QueryRowContext(ctx, "SELECT username FROM users WHERE token = ?", token).Scan(&username) + if err == sql.ErrNoRows { + return "", fmt.Errorf("invalid token") + } + if err != nil { + return "", fmt.Errorf("failed to query user: %w", err) + } + return username, nil +} + +func (m *Manager) Exists(ctx context.Context, username string) (bool, error) { + var count int + err := m.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = ?", username).Scan(&count) + if err != nil { + return false, fmt.Errorf("failed to check user existence: %w", err) + } + return count > 0, nil +} + +func (m *Manager) ensureUserDirectories(username string) error { + deployPath := filepath.Join(m.deployRoot, username) + releasePath := filepath.Join(m.releaseRoot, username) + + if err := os.MkdirAll(deployPath, 0755); err != nil { + return fmt.Errorf("failed to create deploy directory: %w", err) + } + + if err := os.MkdirAll(releasePath, 0755); err != nil { + return fmt.Errorf("failed to create release directory: %w", err) + } + + return nil +} + +func ValidateUsername(username string) bool { + return usernameRegex.MatchString(username) +} + +func ValidateProjectName(project string) bool { + return usernameRegex.MatchString(project) +} + +func generateToken() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + + encoded := base64.URLEncoding.EncodeToString(b) + alphanumeric := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return -1 + }, encoded) + + if len(alphanumeric) < 32 { + for len(alphanumeric) < 32 { + extra := make([]byte, 8) + rand.Read(extra) + extraEncoded := base64.URLEncoding.EncodeToString(extra) + extraAlphanumeric := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return -1 + }, extraEncoded) + alphanumeric += extraAlphanumeric + } + } + + return alphanumeric[:32], nil +} + diff --git a/test-deployment.sh b/test-deployment.sh new file mode 100755 index 0000000..09596d4 --- /dev/null +++ b/test-deployment.sh @@ -0,0 +1,98 @@ +#!/bin/bash +set -e + +echo "=== tingz Service Test ===" +echo "This test script assumes you've deployed the service with .env.development file." +echo "If you haven't, stop running containers, and run `docker compose --env-file .env.development up -d`" +echo + +BASE_URL="http://127.0.0.1:8080" +ADMIN_TOKEN="devadmintoken" + +echo "1. Testing health endpoint..." +curl -s "${BASE_URL}/api/v1/status" | jq . +echo + +echo "2. Creating user 'testuser'..." +RESPONSE=$(curl -s -X POST "${BASE_URL}/api/v1/auth" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"username":"testuser"}') + +echo "$RESPONSE" | jq . +USER_TOKEN=$(echo "$RESPONSE" | jq -r .token) + +if [ "$USER_TOKEN" = "null" ] || [ -z "$USER_TOKEN" ]; then + echo "Error: Failed to create user or extract token" + exit 1 +fi + +echo "User token: $USER_TOKEN" +echo + +echo "3. Creating test static site..." +TEST_DIR=$(mktemp -d) +cat > "${TEST_DIR}/index.html" << 'EOF' + + +Test Deployment +

Hello from Deployer Service!

+ +EOF + +echo "4. Creating tarball..." +TARBALL="${TEST_DIR}/site.tar.gz" +tar -czf "$TARBALL" -C "$TEST_DIR" index.html +echo "Tarball created: $TARBALL" +echo + +echo "5. Deploying to project 'testproject'..." +DEPLOY_RESPONSE=$(curl -s -X POST "${BASE_URL}/api/v1/deploy" \ + -H "Authorization: Bearer ${USER_TOKEN}" \ + -F "project=testproject" \ + -F "file=@${TARBALL}") + +echo "$DEPLOY_RESPONSE" | jq . +echo + +DEPLOY_STATUS=$(echo "$DEPLOY_RESPONSE" | jq -r .status) +if [ "$DEPLOY_STATUS" != "ok" ]; then + echo "Error: Deployment failed" + exit 1 +fi + +echo "6. Verifying deployment files..." +RELEASE_ID=$(echo "$DEPLOY_RESPONSE" | jq -r .release) +echo "Release ID: $RELEASE_ID" + +if [ -d "deploys/testuser/testproject/${RELEASE_ID}" ]; then + echo "✓ Release directory exists" + ls -la "deploys/testuser/testproject/${RELEASE_ID}/" +else + echo "✗ Release directory not found" + exit 1 +fi + +if [ -L "docs/testuser/testproject" ]; then + echo "✓ Symlink exists" + ls -la "docs/testuser/testproject" +else + echo "✗ Symlink not found" + exit 1 +fi + +if [ -f "docs/testuser/testproject/index.html" ]; then + echo "✓ index.html is accessible" + cat "docs/testuser/testproject/index.html" +else + echo "✗ index.html not found" + exit 1 +fi + +echo +echo "7. Cleaning up test files..." +rm -rf "$TEST_DIR" + +echo +echo "=== All tests passed! ===" +