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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ next-env.d.ts
.env.test.local
.env.production.local

# Local dev stack secrets
dev/auth.env
dev/keys/

# Debug logs
npm-debug.log*
yarn-debug.log*
Expand Down
31 changes: 31 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
COMPOSE := docker compose -f docker-compose.dev.yml

.PHONY: dev-setup dev dev-up dev-down dev-logs dev-reset dev-pull

## One-time: generate dev JWT keys + scaffold env files
dev-setup:
@bash dev/setup.sh

## Bring up the full local backend stack, then run the frontend (npm run dev)
dev: dev-up
npm run dev

## Backend stack only (Postgres, Redis, migrate, auth, scraper, calendar, gateway)
dev-up:
$(COMPOSE) up -d --wait

## Pull the latest :dev backend images
dev-pull:
$(COMPOSE) pull

## Stop the stack (keeps data)
dev-down:
$(COMPOSE) down

## Tail logs of the backend stack
dev-logs:
$(COMPOSE) logs -f

## Nuke everything including the database + scraped data
dev-reset:
$(COMPOSE) down -v
32 changes: 32 additions & 0 deletions dev/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# UniPlanner local gateway — path routing only (mirrors prod Traefik).
# Identity headers (X-User-*) are injected by the Next.js dev proxy, not here,
# because auth here is optional (anonymous + public routes must pass through).

{
admin off
auto_https off
}

:8080 {
# Auth service — OAuth flow + user profile/sessions
@auth path /api/v1/auth* /api/v1/users*
handle @auth {
reverse_proxy auth-service:8081
}

# Scraper service — courses, subjects, preview, timetable (public)
@scraper path /api/v1/courses* /api/v1/timetable* /api/v1/preview* /api/v1/scraper*
handle @scraper {
reverse_proxy scraper-service:8083
}

# Calendar manager — calendars API + public .ics feed
@calendar path /api/v1/calendars* /cal/*
handle @calendar {
reverse_proxy calendar-manager:8082
}

handle {
respond "UniPlanner local gateway — unknown route" 404
}
}
74 changes: 74 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Local dev environment

Runs the whole UniPlanner stack locally: Postgres + Redis + migrations + the three
backend services (`:dev` images from GHCR) behind a Caddy gateway, with the Next.js
frontend on the host. Google sign-in works locally; Apple is intentionally not wired.

## Topology

```
browser ─▶ frontend (host :3000)
│ /api/v1/* proxy (adds Bearer + X-User-* in dev)
gateway Caddy :8080 ──┬─▶ auth-service :8081 /api/v1/auth,/users
├─▶ scraper-service :8083 /api/v1/courses,preview,timetable
└─▶ calendar-manager :8082 /api/v1/calendars, /cal/*.ics
Postgres :5432 ─┴─ Redis :6379
```

- **Migrations** run as a prod-like pre-hook: `db-clone` fresh-clones the `databases`
repo, `migrate` applies `up`, and no backend starts until it finishes.
- **Identity**: in prod Istio validates the JWT cookie and injects `X-User-Id/X-Email/X-Roles`.
Locally there is no Istio, so the frontend `/api/v1/[...]` proxy decodes the `access_token`
cookie and injects those headers **in dev only**. Caddy is a pure path router.

## First-time setup

1. **Docker** running, plus this repo's `npm install` done.

2. **Generate keys + env scaffolding:**
```bash
make dev-setup
```
This creates a dev JWT keypair, `dev/auth.env`, and `.env.development.local`
(frontend dev overrides — only used by `next dev`, your prod `.env.local` is untouched).

3. **Create a LOCAL Google OAuth app** (separate from prod so nothing conflicts):
- Google Cloud Console → *APIs & Services* → *Credentials* → *Create OAuth client ID* → *Web application*
- **Authorized JavaScript origins:** `http://localhost:3000`
- **Authorized redirect URIs:** `http://localhost:3000/api/auth/callback`
(Google redirects straight to the frontend callback page, which exchanges the
code via `/api/v1/auth/exchange` and sets the cookies.)
- Put the client id/secret into `dev/auth.env`:
```
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
```

4. **Go:**
```bash
make dev
```
Backend stack comes up (first boot the scraper warms the UniBo course cache — can take a
few minutes), then the frontend starts on http://localhost:3000.

## Commands

| Command | What |
|---|---|
| `make dev-setup` | one-time keys + env scaffolding |
| `make dev` | backend stack (`--wait`) + `npm run dev` |
| `make dev-up` | backend stack only |
| `make dev-pull` | pull latest `:dev` images |
| `make dev-logs` | tail backend logs |
| `make dev-down` | stop (keeps DB + scraped data) |
| `make dev-reset` | **wipe everything** incl. DB volumes |

## Notes

- All API goes through the gateway at `http://localhost:8080`; you can curl it directly
(e.g. `curl 'http://localhost:8080/api/v1/courses?q=informatica&lang=it'`).
- Cookies are host-only, non-secure (`COOKIE_SECURE=false`, no `COOKIE_DOMAIN`) so they work on `localhost`.
- Apple sign-in is not available locally (Apple requires a public HTTPS redirect).
- `dev/auth.env` and `dev/keys/` hold secrets and are gitignored.
31 changes: 31 additions & 0 deletions dev/auth.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
HTTP_PORT=8081
HTTP_IP_HEADER=
ENVIRONMENT=development
LOG_LEVEL=info

DB_HOST=postgres
DB_PORT=5432
DB_USER=unibo_user
DB_PASS=unibo_pass
DB_NAME=unibo_toolkit
DB_MAX_CONNS=10
DB_MIN_CONNS=1

JWT_ISSUER=uniplanner.it
JWT_AUDIENCE=uniplanner.it
JWT_KID=key-2026-01
ACCESS_TOKEN_TTL=900
REFRESH_TOKEN_TTL=2592000

# Generated by `make dev-setup` (dev/setup.sh) — do NOT commit real keys.
JWT_PRIVATE_KEY=
JWT_PUBLIC_KEY=

# Fill these from your LOCAL Google OAuth dev app (see dev/README.md).
# Google redirects the browser straight to the FRONTEND callback page, which then
# POSTs the code to /api/v1/auth/exchange and sets the cookies.
# Google console → Authorized redirect URI: http://localhost:3000/api/auth/callback
# → Authorized JavaScript origin: http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:3000/api/auth/callback
25 changes: 25 additions & 0 deletions dev/calendar.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
HTTP_PORT=8082
ENVIRONMENT=dev
LOG_LEVEL=info

DB_HOST=postgres
DB_PORT=5432
DB_USER=unibo_user
DB_PASS=unibo_pass
DB_NAME=unibo_toolkit
DB_MAX_CONNS=10
DB_MIN_CONNS=2

SCRAPER_URL=http://scraper-service:8083
SCRAPER_TIMEOUT=30

# Public base URL of the local gateway — generated .ics / webcal links point here
CALENDAR_BASE_URL=http://localhost:8080
CALENDAR_PROD_ID=-//UniPlanner//Calendar//EN
CALENDAR_TIMEZONE=Europe/Rome
CALENDAR_DOMAIN=localhost

TTL_ANONYMOUS_DAYS=30
TTL_AUTHENTICATED_DAYS=365

SNOWFLAKE_NODE_ID=1
4 changes: 4 additions & 0 deletions dev/frontend.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
NEXT_PUBLIC_API_URL=http://localhost:8080
AUTH_SERVICE_URL=http://localhost:8080
COOKIE_SECURE=false
COOKIE_DOMAIN=
24 changes: 24 additions & 0 deletions dev/scraper.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
DEBUG_MODE=true
PORT=8083
LOG_LEVEL=INFO

DB_HOST=postgres
DB_PORT=5432
DB_NAME=unibo_toolkit
DB_USER=unibo_user
DB_PASSWORD=unibo_pass

REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=3

SKIP_STARTUP_JOBS=false
FORCE_UPDATE=false

SCRAPER_REQUEST_TIMEOUT=30
SCHEDULER_TIMEZONE=UTC
UPDATE_COURSES_INTERVAL_SECONDS=43200
UPDATE_TIMETABLES_INTERVAL_SECONDS=3600
DELAY_BETWEEN_CURRICULA_REQUESTS=0.1
DELAY_BETWEEN_SITE_URL_REQUESTS=0.1
DELAY_BETWEEN_TIMETABLE_REQUESTS=0.1
44 changes: 44 additions & 0 deletions dev/setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# One-time local dev setup: generate a dev JWT keypair and scaffold env files.
# Safe to re-run — it never overwrites Google creds you've already filled in.
set -euo pipefail

cd "$(dirname "$0")/.."

echo "▸ Generating dev JWT RSA keypair (dev/keys/) …"
mkdir -p dev/keys
if [ ! -f dev/keys/jwt.key ]; then
openssl genrsa -out dev/keys/jwt.key 2048 2>/dev/null
openssl rsa -in dev/keys/jwt.key -pubout -out dev/keys/jwt.pub 2>/dev/null
echo " created dev/keys/jwt.key + jwt.pub"
else
echo " reusing existing dev/keys/jwt.key"
fi

# Join PEM lines into a single \n-escaped string (what auth-service expects).
esc() { awk 'NF{ printf "%s\\n", $0 }' "$1"; }
PRIV="$(esc dev/keys/jwt.key)"
PUB="$(esc dev/keys/jwt.pub)"

echo "▸ Scaffolding dev/auth.env …"
[ -f dev/auth.env ] || cp dev/auth.env.example dev/auth.env
# Inject the keys (ENVIRON avoids awk backslash-escape processing), keep everything else.
PRIV="$PRIV" PUB="$PUB" awk '
/^JWT_PRIVATE_KEY=/ { print "JWT_PRIVATE_KEY=\"" ENVIRON["PRIV"] "\""; next }
/^JWT_PUBLIC_KEY=/ { print "JWT_PUBLIC_KEY=\"" ENVIRON["PUB"] "\""; next }
{ print }
' dev/auth.env > dev/auth.env.tmp && mv dev/auth.env.tmp dev/auth.env

echo "▸ Scaffolding .env.development.local …"
if [ -f .env.development.local ]; then
echo " .env.development.local already exists — left untouched"
else
cp dev/frontend.env.example .env.development.local
echo " created .env.development.local"
fi

echo ""
echo "Setup done. Next steps:"
echo " 1. Create a LOCAL Google OAuth dev app (see dev/README.md) and put"
echo " GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET into dev/auth.env"
echo " 2. Run: make dev"
109 changes: 109 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: uniplanner-dev

services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: unibo_toolkit
POSTGRES_USER: unibo_user
POSTGRES_PASSWORD: unibo_pass
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U unibo_user -d unibo_toolkit"]
interval: 5s
timeout: 5s
retries: 10

redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10

# Migrations: prod-like pre-hook
# Fresh-clone the databases repo, then golang-migrate `up`.
# Backends do not start until `migrate` completes successfully.
db-clone:
image: alpine/git:latest
entrypoint: /bin/sh
command:
- -c
- "rm -rf /repo/databases && git clone --depth 1 https://github.com/unibo-toolkit/databases.git /repo/databases"
volumes:
- migrations_repo:/repo

migrate:
image: migrate/migrate:latest
depends_on:
postgres:
condition: service_healthy
db-clone:
condition: service_completed_successfully
volumes:
- migrations_repo:/repo
command:
- "-path=/repo/databases/migrations"
- "-database=postgresql://unibo_user:unibo_pass@postgres:5432/unibo_toolkit?sslmode=disable"
- "up"

# Backend services (prebuilt :dev images)
auth-service:
image: ghcr.io/unibo-toolkit/auth-service:dev
pull_policy: always
ports:
- "8081:8081"
depends_on:
migrate:
condition: service_completed_successfully
env_file:
- dev/auth.env

scraper-service:
image: ghcr.io/unibo-toolkit/scraper-service:dev
pull_policy: always
ports:
- "8083:8083"
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
env_file:
- dev/scraper.env

calendar-manager:
image: ghcr.io/unibo-toolkit/calendar-manager-service:dev
pull_policy: always
ports:
- "8082:8082"
depends_on:
migrate:
condition: service_completed_successfully
env_file:
- dev/calendar.env

# Gateway: path routing
gateway:
image: caddy:2-alpine
ports:
- "8080:8080"
depends_on:
- auth-service
- scraper-service
- calendar-manager
volumes:
- ./dev/Caddyfile:/etc/caddy/Caddyfile:ro

volumes:
postgres_data:
redis_data:
migrations_repo:
Loading
Loading