diff --git a/.air.toml b/.air.toml index a77074ba..1be3f8c9 100644 --- a/.air.toml +++ b/.air.toml @@ -12,7 +12,6 @@ tmp_dir = "tmp" "tmp", "docs", "database/infra", - "bin", "storage/logs", "storage/media", "config/makefile", diff --git a/.dockerignore b/.dockerignore index ab7aae2c..696032a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,3 @@ -.env -.env.example -.env.production - .gitattributes .github/ .gitignore @@ -17,4 +13,4 @@ database/infra/ storage/media/ storage/logs/ tmp/ -bin/ +caddy/ diff --git a/.env.example b/.env.example index 732da9d9..375dc374 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,31 @@ -# --- App secrets +# --- App ENV_APP_NAME="Gus Blog" ENV_APP_ENV_TYPE=local ENV_APP_ENV=local - -# --- App Logs secrets ENV_APP_LOG_LEVEL=debug ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" ENV_APP_LOGS_DATE_FORMAT="2006_02_01" -# --- App Network secrets -ENV_HTTP_HOST=localhost -ENV_HTTP_PORT=8080 - -# --- App super admin credentials +# --- Auth ENV_APP_TOKEN_PUBLIC="" ENV_APP_TOKEN_PRIVATE="" -# --- App db secrets -ENV_DB_USER_NAME=gocanto-user -ENV_DB_USER_PASSWORD=gocanto-password -ENV_DB_DATABASE_NAME=gocanto-db +# --- DB +ENV_DB_USER_NAME="gus" +ENV_DB_USER_PASSWORD="password" +ENV_DB_DATABASE_NAME="oullin_db" ENV_DB_PORT=5432 ENV_DB_HOST=localhost ENV_DB_SSL_MODE=require ENV_DB_TIMEZONE="Asia/Singapore" -EN_DB_BIN_DIR="" # Local posgreSQL instalation directory. Example: /Library/PostgreSQL/17/bin/ -ENV_DB_URL="" # Example: postgresql://gocanto-user:gocanto-password@postgres:5432/gocanto-db?sslmode=require -# --- Sentry -ENV_SENTRY_DSN="" -ENV_SENTRY_CSP="" +# --- This flag is only needed/used for debugging purposes. +# Local posgreSQL instalation directory. +# Example: /Library/PostgreSQL/17/bin/ +EN_DB_BIN_DIR="" + +# --- This flag is only needed/used for docker purposes. +# The full db url value is only used for docker purposes given the application works in the -GORM DSN- +# Example: postgresql://gocanto-user:gocanto-password@postgres:5432/oullin_db?sslmode=require +ENV_DB_URL="" + diff --git a/.gitignore b/.gitignore index a1feb6ce..e8588ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,19 +4,6 @@ database/infra/data tmp -# --- [API]: Bin -bin -!bin/.env -!bin/.gitkeep -bin/storage/logs/*.* -bin/storage/media/*.* -bin/storage/media/posts/*.* -bin/storage/media/users/*.* -!bin/storage/logs/.gitkeep -!bin/storage/media/.gitkeep -!bin/storage/media/posts/.gitkeep -!bin/storage/media/users/.gitkeep - # --- [API]: Storage storage/logs/*.* storage/media/*.* diff --git a/Makefile b/Makefile index d29cb5cb..044be022 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,6 @@ SOURCE := go_bindata ROOT_PATH := $(shell pwd) APP_PATH := $(ROOT_PATH)/ STORAGE_PATH := $(ROOT_PATH)/storage -BIN_PATH := $(ROOT_PATH)/bin -BIN_LOGS_PATH := $(ROOT_PATH)/bin/storage/logs REPO_OWNER := $(shell cd .. && basename "$$(pwd)") VERSION := $(shell git describe --tags 2>/dev/null | cut -c 2-) @@ -36,7 +34,6 @@ VERSION := $(shell git describe --tags 2>/dev/null | cut -c 2-) include ./config/makefile/helpers.mk include ./config/makefile/env.mk - include ./config/makefile/db.mk include ./config/makefile/app.mk include ./config/makefile/logs.mk @@ -46,22 +43,19 @@ include ./config/makefile/build.mk # -------------------------------------------------------------------------------------------------------------------- # help: - @printf "$(BOLD)$(CYAN)Makefile Commands:$(NC)\n" + @printf "$(BOLD)$(CYAN)Applications Options$(NC)\n" @printf "$(WHITE)Usage:$(NC) make $(BOLD)$(YELLOW)$(NC)\n\n" @printf "$(BOLD)$(BLUE)General Commands:$(NC)\n" - @printf " $(BOLD)$(GREEN)fresh$(NC) : Clean and reset various project components (logs, build, etc.).\n" - @printf " $(BOLD)$(GREEN)audit$(NC) : Run code audits and checks.\n" - @printf " $(BOLD)$(GREEN)watch$(NC) : Start a file watcher process.\n" - @printf " $(BOLD)$(GREEN)format$(NC) : Automatically format code.\n\n" + @printf " $(BOLD)$(GREEN)fresh$(NC) : Clean and reset various project components (logs, build, etc.).\n" + @printf " $(BOLD)$(GREEN)audit$(NC) : Run code audits and checks.\n" + @printf " $(BOLD)$(GREEN)watch$(NC) : Start a file watcher process.\n" + @printf " $(BOLD)$(GREEN)format$(NC) : Automatically format code.\n\n" @printf "$(BOLD)$(BLUE)Build Commands:$(NC)\n" - @printf " $(BOLD)$(GREEN)build:app$(NC) : Build the main application executable.\n" - @printf " $(BOLD)$(GREEN)build:app:linux$(NC) : Build the application specifically for Linux.\n" - @printf " $(BOLD)$(GREEN)build:release$(NC) : Build a release version of the application.\n" - @printf " $(BOLD)$(GREEN)build:run$(NC) : Build and run the application.\n" - @printf " $(BOLD)$(GREEN)build:fresh$(NC) : Build and run a freshly instance of the application.\n" - @printf " $(BOLD)$(GREEN)build:flush$(NC) : Clean build artifacts and then build the application.\n\n" + @printf " $(BOLD)$(GREEN)build:local$(NC) : Build the main application for development.\n" + @printf " $(BOLD)$(GREEN)build:prod$(NC) : Build the main application for production.\n" + @printf " $(BOLD)$(GREEN)build:release$(NC) : Build a release version of the application.\n" @printf "$(BOLD)$(BLUE)Database Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)db:local$(NC) : Set up or manage the local database environment.\n" @@ -81,12 +75,12 @@ help: @printf " $(BOLD)$(GREEN)db:migrate:force$(NC) : Force database migrations to run.\n\n" @printf "$(BOLD)$(BLUE)Environment Commands:$(NC)\n" - @printf " $(BOLD)$(GREEN)env:check$(NC) : Verify environment configuration.\n" - @printf " $(BOLD)$(GREEN)env:fresh$(NC) : Refresh environment settings.\n" - @printf " $(BOLD)$(GREEN)env:init$(NC) : Initialize environment settings.\n" - @printf " $(BOLD)$(GREEN)env:print$(NC) : Display current environment settings.\n\n" + @printf " $(BOLD)$(GREEN)env:check$(NC) : Verify environment configuration.\n" + @printf " $(BOLD)$(GREEN)env:fresh$(NC) : Refresh environment settings.\n" + @printf " $(BOLD)$(GREEN)env:init$(NC) : Initialize environment settings.\n" + @printf " $(BOLD)$(GREEN)env:print$(NC) : Display current environment settings.\n\n" @printf "$(BOLD)$(BLUE)Log Commands:$(NC)\n" - @printf " $(BOLD)$(GREEN)logs:fresh$(NC) : Clear application logs.\n" + @printf " $(BOLD)$(GREEN)logs:fresh$(NC) : Clear application logs.\n" @printf "$(NC)" diff --git a/bin/.gitkeep b/bin/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/boost/factory.go b/boost/factory.go index ea7f2f5e..a1fddf32 100644 --- a/boost/factory.go +++ b/boost/factory.go @@ -10,7 +10,6 @@ import ( "github.com/oullin/pkg/llogs" "log" "strconv" - "strings" "time" ) @@ -56,49 +55,47 @@ func MakeLogs(env *env.Environment) *llogs.Driver { return &lDriver } -func MakeEnv(values map[string]string, validate *pkg.Validator) *env.Environment { +func MakeEnv(validate *pkg.Validator) *env.Environment { errorSufix := "Environment: " - port, _ := strconv.Atoi(values["ENV_DB_PORT"]) + port, _ := strconv.Atoi(env.GetEnvVar("ENV_DB_PORT")) token := auth.Token{ - Public: strings.TrimSpace(values["ENV_APP_TOKEN_PUBLIC"]), - Private: strings.TrimSpace(values["ENV_APP_TOKEN_PRIVATE"]), + Public: env.GetEnvVar("ENV_APP_TOKEN_PUBLIC"), + Private: env.GetEnvVar("ENV_APP_TOKEN_PRIVATE"), } app := env.AppEnvironment{ - Name: strings.TrimSpace(values["ENV_APP_NAME"]), - Type: strings.TrimSpace(values["ENV_APP_ENV_TYPE"]), + Name: env.GetEnvVar("ENV_APP_NAME"), + Type: env.GetEnvVar("ENV_APP_ENV_TYPE"), Credentials: token, } db := env.DBEnvironment{ - UserName: strings.TrimSpace(values["ENV_DB_USER_NAME"]), - UserPassword: strings.TrimSpace(values["ENV_DB_USER_PASSWORD"]), - DatabaseName: strings.TrimSpace(values["ENV_DB_DATABASE_NAME"]), + UserName: env.GetEnvVar("ENV_DB_USER_NAME"), + UserPassword: env.GetEnvVar("ENV_DB_USER_PASSWORD"), + DatabaseName: env.GetEnvVar("ENV_DB_DATABASE_NAME"), Port: port, - Host: strings.TrimSpace(values["ENV_DB_HOST"]), - DriverName: "postgres", - BinDir: strings.TrimSpace(values["EN_DB_BIN_DIR"]), - URL: strings.TrimSpace(values["ENV_DB_URL"]), - SSLMode: strings.TrimSpace(values["ENV_DB_SSL_MODE"]), - TimeZone: strings.TrimSpace(values["ENV_DB_TIMEZONE"]), + Host: env.GetEnvVar("ENV_DB_HOST"), + DriverName: database.DriverName, + SSLMode: env.GetEnvVar("ENV_DB_SSL_MODE"), + TimeZone: env.GetEnvVar("ENV_DB_TIMEZONE"), } logsCreds := env.LogsEnvironment{ - Level: strings.TrimSpace(values["ENV_APP_LOG_LEVEL"]), - Dir: strings.TrimSpace(values["ENV_APP_LOGS_DIR"]), - DateFormat: strings.TrimSpace(values["ENV_APP_LOGS_DATE_FORMAT"]), + Level: env.GetEnvVar("ENV_APP_LOG_LEVEL"), + Dir: env.GetEnvVar("ENV_APP_LOGS_DIR"), + DateFormat: env.GetEnvVar("ENV_APP_LOGS_DATE_FORMAT"), } net := env.NetEnvironment{ - HttpHost: strings.TrimSpace(values["ENV_HTTP_HOST"]), - HttpPort: strings.TrimSpace(values["ENV_HTTP_PORT"]), + HttpHost: env.GetEnvVar("ENV_HTTP_HOST"), + HttpPort: env.GetEnvVar("ENV_HTTP_PORT"), } sentryEnvironment := env.SentryEnvironment{ - DSN: strings.TrimSpace(values["ENV_SENTRY_DSN"]), - CSP: strings.TrimSpace(values["ENV_SENTRY_CSP"]), + DSN: env.GetEnvVar("ENV_SENTRY_DSN"), + CSP: env.GetEnvVar("ENV_SENTRY_CSP"), } if _, err := validate.Rejects(app); err != nil { diff --git a/boost/ignite.go b/boost/ignite.go index 03d73e0e..c3e72402 100644 --- a/boost/ignite.go +++ b/boost/ignite.go @@ -6,14 +6,10 @@ import ( "github.com/oullin/pkg" ) -func Ignite(envPath string) (*env.Environment, *pkg.Validator) { - validate := pkg.GetDefaultValidator() - - envMap, err := godotenv.Read(envPath) - - if err != nil { - panic("failed to read the .env file: " + err.Error()) +func Ignite(envPath string, validate *pkg.Validator) *env.Environment { + if err := godotenv.Load(envPath); err != nil { + panic("failed to read the .env file/values: " + err.Error()) } - return MakeEnv(envMap, validate), validate + return MakeEnv(validate) } diff --git a/caddy/Caddyfile.local b/caddy/Caddyfile.local new file mode 100644 index 00000000..dcb83ea2 --- /dev/null +++ b/caddy/Caddyfile.local @@ -0,0 +1,24 @@ +# Filename: caddy/Caddyfile + +# This global options block explicitly disables Caddy's automatic HTTPS feature. +# This is the most reliable way to ensure Caddy acts as a simple HTTP proxy. +{ + auto_https off +} + +# This is a robust configuration for a containerized environment. +# It tells Caddy to listen on its internal port 80 for any incoming hostname. +# Docker Compose maps your host port (8080) to this container port. +:80 { + # Define a logging format for easier debugging. + log { + output stdout + format console + } + + # Reverse proxy all incoming requests to the 'api' service. + # The service name 'api' is resolved by Docker's internal DNS to the + # correct container IP on the 'caddy_net' network. + # The API container listens on port 8080 (from your ENV_HTTP_PORT). + reverse_proxy api:8080 +} diff --git a/caddy/Caddyfile.prod b/caddy/Caddyfile.prod new file mode 100644 index 00000000..b6dfb0bb --- /dev/null +++ b/caddy/Caddyfile.prod @@ -0,0 +1,23 @@ +# Filename: caddy/Caddyfile.prod +# Caddy will automatically provision a Let's Encrypt certificate. + +gocanto.dev { + # Enable compression to reduce bandwidth usage. + encode gzip zstd + + # Add security-related headers to protect against common attacks. + header { + # Enable HSTS to ensure browsers only connect via HTTPS. + Strict-Transport-Security "max-age=31536000;" + # Prevent clickjacking attacks. + X-Frame-Options "SAMEORIGIN" + # Prevent content type sniffing. + X-Content-Type-Options "nosniff" + # Enhances user privacy. + Referrer-Policy "strict-origin-when-cross-origin" + } + + # Reverse proxy all requests to the Go application service. + # 'api' is the service name defined in docker-compose.yml. + reverse_proxy api:8080 +} diff --git a/caddy/Dockerfile b/caddy/Dockerfile new file mode 100644 index 00000000..0e83db19 --- /dev/null +++ b/caddy/Dockerfile @@ -0,0 +1,13 @@ +# Filename: caddy/Dockerfile +# This Dockerfile builds a Caddy image using a specific, stable version number. + +# Define a build argument for the Caddy version with a sensible default. +# This allows the version to be easily overridden from the docker-compose.yml file. +ARG CADDY_VERSION=2.10.0 + +# Use the official Caddy image with the latest tag. +FROM caddy:${CADDY_VERSION} + +# Copy your custom Caddyfile into the container. +# This overwrites the default Caddyfile. +COPY Caddyfile.local /etc/caddy/Caddyfile diff --git a/cli/main.go b/cli/main.go index 79996bee..57d9726b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -16,7 +16,7 @@ var guard gate.Guard var environment *env.Environment func init() { - secrets, _ := boost.Ignite("./../.env") + secrets := boost.Ignite("./../.env", pkg.GetDefaultValidator()) environment = secrets guard = gate.MakeGuard(environment.App.Credentials) diff --git a/config/makefile/app.mk b/config/makefile/app.mk index c2425ab1..7c92242a 100644 --- a/config/makefile/app.mk +++ b/config/makefile/app.mk @@ -14,17 +14,17 @@ fresh: audit: $(call external_deps,'.') - $(call external_deps,'./bin/...') $(call external_deps,'./app/...') $(call external_deps,'./database/...') $(call external_deps,'./docs/...') watch: # --- Works with (air). - # https://github.com/air-verse/air + # https://github.com/air-verse/air cd $(APP_PATH) && air install-air: - # https://github.com/air-verse/air + # --- Works with (air). + # https://github.com/air-verse/air @echo "Installing air ..." @go install github.com/air-verse/air@latest diff --git a/config/makefile/build.mk b/config/makefile/build.mk index 6210f9f1..64724fc3 100644 --- a/config/makefile/build.mk +++ b/config/makefile/build.mk @@ -1,50 +1,11 @@ -.PHONY: build\:app build\:flush build\:app\:linux build\:release build\:run build\:fresh +.PHONY: build\:local build\:prod build\:release -___BIN___ROOT__PATH := $(shell pwd) -___BIN___ENV___FILE__TEMPLATE := .env.production -___BIN___FULL__PATH := $(___BIN___ROOT__PATH)/bin -___BIN___ENV___FILE := $(___BIN___FULL__PATH)/.env -___BIN___APP___FILE := $(___BIN___FULL__PATH)/app +build\:local: + docker compose --profile local up --build -d -# --- Storage -___BIN___STORAGE__PATH := $(___BIN___FULL__PATH)/storage -___BIN___LOGS__PATH := $(___BIN___STORAGE__PATH)/logs - - -build\:fresh: - make build:app && make build:run - -build\:run: - cd $(___BIN___FULL__PATH) && ./app - -# -build\:app\:linux: - @printf "\n$(BLUE)[BIN]$(NC) Building the app in [amd64] has started.\n" - make build:env && \ - make build:flush && \ - cd $(APP_PATH) && \ - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o "$(ROOT_PATH)/bin/app_linux" -ldflags='-X main.Version=$(VERSION) -extldflags "-static"' -tags '$(DATABASE) $(SOURCE)' $(APP_PATH) +build\:prod: + docker compose --profile prod up --build -d build\:release: git tag v$(V) @read -p "Press enter to confirm and push to origin ..." && git push origin v$(V) - -build\:app: - @printf "\n$(BLUE)[BIN]$(NC) Building the app has started.\n" - make build:env && \ - make build:flush && \ - CGO_ENABLED=0 go build -a -ldflags='-X main.Version=$(VERSION)' -o "$(ROOT_PATH)/bin/app" -tags '$(DATABASE) $(SOURCE)' $(APP_PATH) - @printf "$(GREEN)[BIN]$(NC) Building the app has finished.\n\n" - -build\:flush: - @printf "$(BLUE)[BIN]$(NC) Flushing the previous builds has started.\n" - @sleep 1 - rm -f $(___BIN___ENV___FILE) - rm -f $(___BIN___APP___FILE) - rm -rf $(___BIN___LOGS__PATH) - mkdir -m 755 $(___BIN___LOGS__PATH) - touch $(___BIN___LOGS__PATH)/.gitkeep - @printf "$(GREEN)[BIN]$(NC) Flushing has finished.\n\n" - -build\:env: - cp $(___BIN___ENV___FILE__TEMPLATE) $(___BIN___ENV___FILE) diff --git a/config/makefile/db.mk b/config/makefile/db.mk index 5674e85a..3c8f30fc 100644 --- a/config/makefile/db.mk +++ b/config/makefile/db.mk @@ -5,7 +5,7 @@ # --- Docker DB_DOCKER_SERVICE_NAME := postgres -DB_DOCKER_CONTAINER_NAME := gocanto-db +DB_DOCKER_CONTAINER_NAME := oullin_db # --- Paths DB_SEEDER_ROOT_PATH := $(ROOT_PATH)/database/seeder diff --git a/config/makefile/logs.mk b/config/makefile/logs.mk index f5570eaf..b0a5af0e 100644 --- a/config/makefile/logs.mk +++ b/config/makefile/logs.mk @@ -2,5 +2,3 @@ logs\:fresh: find $(STORAGE_PATH)/logs -maxdepth 1 -type f -not -name ".gitkeep" -delete - - diff --git a/database/connection.go b/database/connection.go index 5eeac376..d78c2181 100644 --- a/database/connection.go +++ b/database/connection.go @@ -25,7 +25,6 @@ func MakeConnection(env *env.Environment) (*Connection, error) { } return &Connection{ - url: dbEnv.URL, driver: driver, driverName: dbEnv.DriverName, env: env, diff --git a/database/model.go b/database/model.go index 59c2089b..b1e98c2a 100644 --- a/database/model.go +++ b/database/model.go @@ -6,6 +6,8 @@ import ( "time" ) +const DriverName = "postgres" + var schemaTables = []string{ "users", "posts", "categories", "post_categories", "tags", "post_tags", diff --git a/database/seeder/main.go b/database/seeder/main.go index a9669531..de915045 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -5,6 +5,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/seeder/seeds" "github.com/oullin/env" + "github.com/oullin/pkg" "github.com/oullin/pkg/cli" "sync" "time" @@ -13,7 +14,7 @@ import ( var environment *env.Environment func init() { - secrets, _ := boost.Ignite("./.env") + secrets := boost.Ignite("./.env", pkg.GetDefaultValidator()) environment = secrets } diff --git a/docker-compose.yml b/docker-compose.yml index f47aad68..757468a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,53 +1,132 @@ +volumes: + caddy_data: + caddy_config: + networks: - gocanto: - name: gocanto - driver: bridge + caddy_net: + name: caddy_net + driver: bridge + oullin_net: + name: oullin_net + driver: bridge services: - postgres: - restart: always - image: postgres:17.4 - container_name: gocanto-db - env_file: - - .env - networks: - - gocanto - environment: - # --- Postgres CLI env vars. - PGUSER: ${ENV_DB_USER_NAME} - PGDATABASE: ${ENV_DB_DATABASE_NAME} - PGPASSWORD: ${ENV_DB_USER_PASSWORD} - # --- Docker postgres-image env vars. - POSTGRES_USER: ${ENV_DB_USER_NAME} - POSTGRES_DB: ${ENV_DB_DATABASE_NAME} - POSTGRES_PASSWORD: ${ENV_DB_USER_PASSWORD} - ports: - - "${ENV_DB_PORT}:${ENV_DB_PORT}" - volumes: - - ./database/infra/ssl/server.crt:/etc/ssl/certs/server.crt - - ./database/infra/ssl/server.key:/etc/ssl/private/server.key - - ./database/infra/data:/var/lib/postgresql/data - - ./database/infra/config/postgresql.conf:/etc/postgresql/postgresql.conf - - ./database/infra/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d - logging: - driver: "json-file" - options: - max-file: "20" - max-size: "10M" + caddy_prod: + build: + context: ./caddy + dockerfile: Dockerfile + args: + - CADDY_VERSION=2.10.0 + # This service will only run when the 'prod' profile is active. + profiles: ["prod"] + container_name: oullin_proxy_prod + restart: unless-stopped + depends_on: + - api + ports: + - "80:80" + - "443:443" + - "443:443/udp" # Required for HTTP/3 + volumes: + - caddy_data:/data + - caddy_config:/config + - ./caddy/Caddyfile.prod:/etc/caddy/Caddyfile + networks: + - caddy_net + + caddy_local: + build: + context: ./caddy + dockerfile: Dockerfile + args: + - CADDY_VERSION=latest + # This service will only run when the 'local' profile is active. + profiles: ["local"] + container_name: oullin_local_proxy + restart: unless-stopped + depends_on: + - api + ports: + - "8080:80" + - "8443:443" + volumes: + - caddy_data:/data + - caddy_config:/config + - ./caddy/Caddyfile.local:/etc/caddy/Caddyfile + networks: + - caddy_net + + api: + env_file: + - .env + environment: + # This ensures the API connects to the correct database container. + ENV_DB_HOST: postgres + # This ensures the Go web server listens for connections from other + # containers (like Caddy), not just from within itself. + ENV_HTTP_HOST: 0.0.0.0 + build: + context: . + dockerfile: ./docker/dockerfile-api + args: + - APP_VERSION=v1.0.0-release + - APP_HOST_PORT=${ENV_HTTP_PORT} + - APP_USER=${ENV_DOCKER_USER} + - APP_GROUP=${ENV_DOCKER_USER_GROUP} + container_name: oullin_api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + expose: + - ${ENV_HTTP_PORT} + networks: + - caddy_net + - oullin_net - command: > - postgres -c config_file=/etc/postgresql/postgresql.conf + postgres: + restart: unless-stopped + image: postgres:17.4 + container_name: oullin_db + env_file: + - .env + networks: + - oullin_net + environment: + # --- Postgres CLI env vars. + PGUSER: ${ENV_DB_USER_NAME} + PGDATABASE: ${ENV_DB_DATABASE_NAME} + PGPASSWORD: ${ENV_DB_USER_PASSWORD} + # --- Docker postgres-image env vars. + POSTGRES_USER: ${ENV_DB_USER_NAME} + POSTGRES_DB: ${ENV_DB_DATABASE_NAME} + POSTGRES_PASSWORD: ${ENV_DB_USER_PASSWORD} + ports: + - "${ENV_DB_PORT}:${ENV_DB_PORT}" + volumes: + - ./database/infra/ssl/server.crt:/etc/ssl/certs/server.crt + - ./database/infra/ssl/server.key:/etc/ssl/private/server.key + - ./database/infra/data:/var/lib/postgresql/data + - ./database/infra/config/postgresql.conf:/etc/postgresql/postgresql.conf + - ./database/infra/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d + logging: + driver: "json-file" + options: + max-file: 20 + max-size: 10M + command: > + postgres -c config_file=/etc/postgresql/postgresql.conf - healthcheck: - interval: 10s - timeout: 5s - retries: 5 - test: [ - "CMD-SHELL", - "pg_isready", - "--username=${ENV_DB_USER_NAME}", - "--dbname=${ENV_DB_DATABASE_NAME}", - "--host=postgres", - "--port=${ENV_DB_PORT}", - "--version" - ] + healthcheck: + interval: 10s + timeout: 5s + retries: 5 + test: [ + "CMD-SHELL", + "pg_isready", + "--username=${ENV_DB_USER_NAME}", + "--dbname=${ENV_DB_DATABASE_NAME}", + "--host=postgres", + "--port=${ENV_DB_PORT}", + "--version" + ] diff --git a/docker/dockerfile-api b/docker/dockerfile-api new file mode 100644 index 00000000..6390d20a --- /dev/null +++ b/docker/dockerfile-api @@ -0,0 +1,127 @@ +# API Docker File. + +# --- Build Arguments for Configuration --- +# Defines variables that make this Dockerfile more configurable and reusable. +# These can be overridden during the build process (e.g., via docker-compose). +ARG GO_VERSION=1.24 +ARG ALPINE_VERSION=latest + +ARG APP_VERSION="0.0.0-dev" +ARG BUILD_TAGS="posts,experience,profile,projects,social,talks,gus,gocanto" + +ARG BINARY_NAME=server +ARG APP_HOST_PORT=8080 +ARG APP_USER=appuser +ARG APP_GROUP=appgroup +ARG APP_HOME=/home/${APP_USER} + +ARG BUILD_DIR=/app +ARG STORAGE_DIR=storage +ARG LOGS_DIR=logs +ARG MEDIA_DIR=media +ARG FIXTURES_DIR=fixture + +# --- Build Stage --- +# This stage, named 'builder', is responsible for compiling the Go application. +# It uses a Go-specific base image that includes the necessary toolchain. +FROM golang:${GO_VERSION}-alpine AS builder + +# Forwards build-time arguments into this specific stage so they can be referenced. +ARG BUILD_DIR +ARG BINARY_NAME +ARG APP_VERSION +ARG BUILD_TAGS + +# Installs the timezone database package into the builder image. +# This is necessary because the base 'alpine' image is minimal and does not +# include this data by default. It is copied to the final image later. +RUN apk add --no-cache tzdata + +# Sets the primary working directory for this stage of the build. +WORKDIR ${BUILD_DIR} + +# Copies the Go module definition files into the builder. +# This is done first to leverage Docker's layer caching. The subsequent +# 'go mod download' step will only be re-run if these files have changed. +COPY go.mod go.sum ./ + +# Downloads the application's external dependencies as specified in the go.mod file. +# The 'replace' directive in go.mod is used to resolve local packages, so this +# command will not attempt to download them from the internet. +RUN go mod download + +# Copies the rest of the application's source code into the builder. +# This includes the main package and any local packages like 'boost'. +COPY . . + +# Compiles the Go application into a single, statically-linked binary. +# -tags: Applies build constraints, allowing for conditional compilation. +# -o: Specifies the output path and name for the compiled binary. +# -ldflags: Provides flags to the linker. +# -s: Strips the symbol table, reducing binary size. +# -w: Strips DWARF debugging information, further reducing size. +# -X: Injects a value into a string variable at build time. Here, it sets +# the application's version by targeting the 'Version' variable in the 'main' package. +RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -o ${BUILD_DIR}/${BINARY_NAME} -ldflags="-s -w -X main.Version=${APP_VERSION}" . + +# --- Final Stage +# This is the final, production-ready stage of the build. +# It uses a minimal 'alpine' base image to create a small and secure container. +FROM alpine:${ALPINE_VERSION} + +# Forwards build-time arguments into this final stage so they can be referenced. +ARG APP_USER +ARG APP_GROUP +ARG APP_HOME +ARG BUILD_DIR +ARG BINARY_NAME +ARG STORAGE_DIR +ARG LOGS_DIR +ARG MEDIA_DIR +ARG FIXTURES_DIR + +# Creates a dedicated, non-root user and group for the application. +# Running the application as a non-root user is a critical security best practice. +RUN addgroup -S ${APP_GROUP} && adduser -S ${APP_USER} -G ${APP_GROUP} + +# Sets the working directory for the final container. +WORKDIR ${APP_HOME} + +# Creates the necessary storage directories inside the container. +# These folders will be owned by the application user and can be used for runtime file generation. +RUN mkdir -p ${STORAGE_DIR}/${LOGS_DIR} ${STORAGE_DIR}/${MEDIA_DIR} + +# Copies the 'fixture' files from the local project directory into the container. +# This is useful for including seed data or other essential files with the application. +COPY ${STORAGE_DIR}/${FIXTURES_DIR} ./${STORAGE_DIR}/${FIXTURES_DIR}/ + +# Copies the compiled application binary from the 'builder' stage. +# This is the core of the multi-stage build pattern, ensuring the final image +# contains only the compiled application and not the Go toolchain or source code. +COPY --from=builder ${BUILD_DIR}/${BINARY_NAME} . + +# Copies the timezone database from the 'builder' stage. +# This ensures that time-related functions in the application work correctly. +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo + +# Copies the .env file into the container. +# This allows the application to load its configuration from environment variables. +# For this to work, '.env' must not be in the .dockerignore file. +COPY .env . + +# Recursively sets the ownership of all files in the application's home directory. +# This ensures the non-root application user has the correct permissions to execute the binary +# and write to the storage directories. +RUN chown -R ${APP_USER}:${APP_GROUP} ${APP_HOME} + +# Switches the context of the container to run as the non-root user. +# Any subsequent commands (like the CMD) will be executed by this user. +USER ${APP_USER} + +# Exposes the application's port from the container. +# This does not publish the port; it serves as documentation for which port to map. +EXPOSE ${APP_HOST_PORT} + +# Defines the default command to execute when the container starts. +# This runs the compiled application binary. +CMD ["./server"] diff --git a/env/db.go b/env/db.go index de3dae6b..18b96c9d 100644 --- a/env/db.go +++ b/env/db.go @@ -5,12 +5,10 @@ import "fmt" type DBEnvironment struct { UserName string `validate:"required,lowercase,min=10"` UserPassword string `validate:"required,min=10"` - DatabaseName string `validate:"required,lowercase,min=10"` + DatabaseName string `validate:"required,lowercase,min=5"` Port int `validate:"required,numeric,gt=0"` Host string `validate:"required,lowercase,hostname"` DriverName string `validate:"required,lowercase,oneof=postgres"` - BinDir string - URL string `validate:"required,lowercase,startswith=postgres"` SSLMode string `validate:"required,lowercase,oneof=require"` TimeZone string `validate:"required"` } diff --git a/env/env.go b/env/env.go index dfc4e5da..6bb4c657 100644 --- a/env/env.go +++ b/env/env.go @@ -1,5 +1,10 @@ package env +import ( + "os" + "strings" +) + type Environment struct { App AppEnvironment DB DBEnvironment @@ -7,3 +12,7 @@ type Environment struct { Network NetEnvironment Sentry SentryEnvironment } + +func GetEnvVar(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} diff --git a/env/network.go b/env/network.go index 195425dd..f66d91c6 100644 --- a/env/network.go +++ b/env/network.go @@ -1,7 +1,7 @@ package env type NetEnvironment struct { - HttpHost string `validate:"required,lowercase,min=8"` + HttpHost string `validate:"required,lowercase,min=7"` HttpPort string `validate:"required,numeric,oneof=8080"` } diff --git a/go.mod b/go.mod index e91df159..1150f536 100644 --- a/go.mod +++ b/go.mod @@ -32,3 +32,5 @@ require ( golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect ) + +replace github.com/oullin/boost => ./boost diff --git a/main.go b/main.go index 15f6adf4..046b47c1 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( _ "github.com/lib/pq" "github.com/oullin/boost" + "github.com/oullin/pkg" "log/slog" baseHttp "net/http" ) @@ -10,7 +11,9 @@ import ( var app *boost.App func init() { - secrets, validate := boost.Ignite("./.env") + validate := pkg.GetDefaultValidator() + + secrets := boost.Ignite("./.env", validate) app = boost.MakeApp(secrets, validate) }