Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 20 additions & 71 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,35 +25,22 @@ jobs:
- name: Create placeholder env files
run: |
touch .env
mkdir -p backend telegram-bot
touch backend/.env telegram-bot/.env
mkdir -p backend
touch backend/.env

- name: Validate local Docker Compose
run: docker compose config

- name: Validate server Docker Compose
env:
BACKEND_IMAGE: ghcr.io/example/focus/backend:test
TELEGRAM_BOT_IMAGE: ghcr.io/example/focus/telegram-bot:test
DATABASE_URL: postgres://user:pass@db:5432/focus?sslmode=disable
JWT_SECRET: test-secret
TELEGRAM_BOT_AUTH_TOKEN: test-bot-auth
TELEGRAM_BOT_TOKEN: test-bot-token
run: docker compose -f deploy/docker-compose.server.yml config

build-and-push-images:
needs: compose-validate
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- service: backend
context: ./backend
image_suffix: backend
- service: telegram-bot
context: ./telegram-bot
image_suffix: telegram-bot
steps:
- uses: actions/checkout@v4

Expand All @@ -65,16 +52,16 @@ jobs:
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push ${{ matrix.service }}
- name: Build and push backend
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
context: ./backend
push: true
tags: |
ghcr.io/${{ github.repository }}/${{ matrix.image_suffix }}:${{ github.sha }}
ghcr.io/${{ github.repository }}/${{ matrix.image_suffix }}:latest
ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
ghcr.io/${{ github.repository }}/backend:latest
cache-from: type=gha
cache-to: type=gha,mode=max

Expand All @@ -92,7 +79,7 @@ jobs:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
BACKEND_IMAGE: ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
TELEGRAM_BOT_IMAGE: ghcr.io/${{ github.repository }}/telegram-bot:${{ github.sha }}
GITHUB_ACTOR: ${{ github.actor }}
steps:
- uses: actions/checkout@v4

Expand All @@ -108,37 +95,22 @@ jobs:
server_host="${server_host%%/*}"

if [[ "$server_host" == *"@"* ]]; then
if [ -z "$server_user" ]; then
server_user="${server_host%@*}"
fi
[ -z "$server_user" ] && server_user="${server_host%@*}"
server_host="${server_host#*@}"
fi

if printf '%s' "$server_host" | grep -Eq '^[^:/]+:[0-9]+$'; then
if [ -z "$server_port" ]; then
server_port="${server_host##*:}"
fi
[ -z "$server_port" ] && server_port="${server_host##*:}"
server_host="${server_host%:*}"
fi

: "${server_user:=ubuntu}"
: "${server_port:=22}"
: "${deploy_path:=/opt/focus}"

if [ -z "$server_host" ]; then
echo "SERVER_IP secret is empty or invalid" >&2
exit 1
fi

if [ -z "$SSH_PRIVATE_KEY" ]; then
echo "SSH_PRIVATE_KEY secret is empty" >&2
exit 1
fi

if [ -z "$GHCR_TOKEN" ]; then
echo "GHCR_TOKEN secret is empty" >&2
exit 1
fi
[ -z "$server_host" ] && { echo "SERVER_IP secret is empty or invalid" >&2; exit 1; }
[ -z "$SSH_PRIVATE_KEY" ] && { echo "SSH_PRIVATE_KEY secret is empty" >&2; exit 1; }
[ -z "$GHCR_TOKEN" ] && { echo "GHCR_TOKEN secret is empty" >&2; exit 1; }

{
echo "SERVER_HOST=$server_host"
Expand All @@ -163,54 +135,31 @@ jobs:
ssh -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" "mkdir -p '$DEPLOY_PATH'"
scp -P "$SERVER_PORT" deploy/docker-compose.server.yml "$SERVER_USER@$SERVER_HOST:$DEPLOY_PATH/docker-compose.yml"

- name: Render production env file
- name: Render and upload env file
run: |
cat > deploy.env <<EOF
BACKEND_IMAGE=${BACKEND_IMAGE}
TELEGRAM_BOT_IMAGE=${TELEGRAM_BOT_IMAGE}
DOCKER_NETWORK=${{ secrets.DOCKER_NETWORK }}
BACKEND_PORT=${{ secrets.BACKEND_PORT }}
BOT_PORT=${{ secrets.BOT_PORT }}
PORT=${{ secrets.PORT }}
DATABASE_URL=${{ secrets.DATABASE_URL }}
CORS_ORIGIN=${{ secrets.CORS_ORIGIN }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
ENABLE_DEV_LOGIN=${{ secrets.ENABLE_DEV_LOGIN }}
REFRESH_SESSION_TTL_HOURS=${{ secrets.REFRESH_SESSION_TTL_HOURS }}
MAX_ACTIVE_AUTH_SESSIONS=${{ secrets.MAX_ACTIVE_AUTH_SESSIONS }}
AUTH_SESSION_BIND_CLIENT=${{ secrets.AUTH_SESSION_BIND_CLIENT }}
MAX_SESSION_PAUSES=${{ secrets.MAX_SESSION_PAUSES }}
TELEGRAM_BOT_AUTH_TOKEN=${{ secrets.TELEGRAM_BOT_AUTH_TOKEN }}
TELEGRAM_LINK_CODE_TTL_MINUTES=${{ secrets.TELEGRAM_LINK_CODE_TTL_MINUTES }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
RESEND_FROM_EMAIL=${{ secrets.RESEND_FROM_EMAIL }}
EMAIL_OUTBOX_POLL_SECONDS=${{ secrets.EMAIL_OUTBOX_POLL_SECONDS }}
EMAIL_OUTBOX_MAX_ATTEMPTS=${{ secrets.EMAIL_OUTBOX_MAX_ATTEMPTS }}
EMAIL_VERIFY_URL_BASE=${{ secrets.EMAIL_VERIFY_URL_BASE }}
EMAIL_VERIFY_SUCCESS_REDIRECT=${{ secrets.EMAIL_VERIFY_SUCCESS_REDIRECT }}
EMAIL_VERIFY_FAIL_REDIRECT=${{ secrets.EMAIL_VERIFY_FAIL_REDIRECT }}
EMAIL_VERIFICATION_TTL_MINUTES=${{ secrets.EMAIL_VERIFICATION_TTL_MINUTES }}
EMAIL_RESET_PASSWORD_URL_BASE=${{ secrets.EMAIL_RESET_PASSWORD_URL_BASE }}
TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}
BACKEND_URL=${{ secrets.BACKEND_URL }}
APP_URL=${{ secrets.APP_URL }}
TELEGRAM_POLL_TIMEOUT_SECONDS=${{ secrets.TELEGRAM_POLL_TIMEOUT_SECONDS }}
BOT_INTERNAL_API_ADDR=${{ secrets.BOT_INTERNAL_API_ADDR }}
BACKEND_URL=${{ secrets.BACKEND_URL }}
EOF

- name: Upload env file
run: |
scp -P "$SERVER_PORT" deploy.env "$SERVER_USER@$SERVER_HOST:$DEPLOY_PATH/.env"

- name: Deploy on server
run: |
ssh -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" <<EOF
ssh -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" bash -s <<EOF
set -euo pipefail
cd "$DEPLOY_PATH"

echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker compose --env-file .env pull
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker compose --env-file .env pull backend migrate
docker compose --env-file .env run --rm migrate
docker compose --env-file .env up -d backend telegram-bot
docker compose --env-file .env up -d backend
docker image prune -f
EOF
EOF
2 changes: 0 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ REFRESH_SESSION_TTL_HOURS=720
MAX_ACTIVE_AUTH_SESSIONS=5
AUTH_SESSION_BIND_CLIENT=true
MAX_SESSION_PAUSES=3
TELEGRAM_BOT_AUTH_TOKEN=dev-telegram-bot-auth-change-me
TELEGRAM_LINK_CODE_TTL_MINUTES=10
RESEND_API_KEY=
RESEND_FROM_EMAIL=
EMAIL_OUTBOX_POLL_SECONDS=2
Expand Down
25 changes: 9 additions & 16 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
# syntax=docker/dockerfile:1.7

FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

RUN go mod download -x
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/backend ./cmd/api
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/migrate ./cmd/migrate
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/backend ./cmd/api \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/migrate ./cmd/migrate

FROM alpine:3.21
RUN apk add --no-cache ca-certificates \
&& addgroup -S app \
&& adduser -S -G app app

&& addgroup -S app \
&& adduser -S -G app app
WORKDIR /app
COPY --from=builder /out/backend /app/backend
COPY --from=builder /out/migrate /app/migrate
COPY --from=builder /src/migrations /app/migrations

COPY --from=builder /out/backend /out/migrate ./
COPY --from=builder /src/migrations ./migrations
USER app
EXPOSE 8080

ENTRYPOINT ["/app/backend"]
ENTRYPOINT ["/app/backend"]
28 changes: 19 additions & 9 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
Expand All @@ -21,18 +22,26 @@ import (
)

func main() {
// Initialize Structured Logging (slog)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

cfg, err := config.Load()
if err != nil {
log.Fatalf("config error: %v", err)
slog.Error("failed to load config", "error", err)
os.Exit(1)
}

// Infrastructure
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("database error: %v", err)
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()

Expand All @@ -54,11 +63,11 @@ func main() {
migrationsDir = "migrations"
}
}
log.Printf("applying migrations from: %s", migrationsDir)
slog.Info("applying migrations", "dir", migrationsDir)
if err := postgres.ApplyMigrations(ctx, pool, migrationsDir); err != nil {
log.Printf("warning: migration error: %v", err)
slog.Warn("migration error", "error", err)
} else {
log.Println("migrations applied successfully")
slog.Info("migrations applied successfully")
}

repo := postgres.NewRepository(pool, cfg.MaxSessionPauses, cfg.MaxActiveAuthSessions, cfg.AuthSessionBindClient)
Expand Down Expand Up @@ -92,12 +101,12 @@ func main() {
goalUC := usecase.NewGoalUseCase(repo)
analyticsUC := usecase.NewAnalyticsUseCase(repo, repo)
shopUC := usecase.NewShopUseCase(repo)
telegramUC := usecase.NewTelegramUseCase(repo, time.Duration(cfg.TelegramLinkCodeTTLMinutes)*time.Minute)

notificationUC := usecase.NewNotificationUseCase(repo)
preferencesUC := usecase.NewPreferencesUseCase(repo, repo)

// Delivery
handler := httpapi.NewHandler(authUC, sessionUC, goalUC, analyticsUC, shopUC, telegramUC, notificationUC, preferencesUC, repo, emailOutbox, repo, cfg.TelegramBotAuthToken)
handler := httpapi.NewHandler(authUC, sessionUC, goalUC, analyticsUC, shopUC, notificationUC, preferencesUC, repo, emailOutbox, repo)
router := httpapi.NewRouter(handler, cfg.CorsOrigin, cfg.EnableDevLogin, jwtSvc.ValidateToken, cfg.JWTSecret)

srv := &http.Server{
Expand All @@ -116,8 +125,9 @@ func main() {
}
}()

log.Printf("Mathalama Focus backend listening on :%s", cfg.Port)
slog.Info("Mathalama Focus backend listening", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
slog.Error("server error", "error", err)
os.Exit(1)
}
}
22 changes: 11 additions & 11 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
module mathalama-focus/backend

go 1.24
go 1.25.0

require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.30.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.1
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.20.5
golang.org/x/crypto v0.49.0
golang.org/x/time v0.7.0
)

require (
Expand All @@ -18,13 +23,11 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
Expand All @@ -38,19 +41,16 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading