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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ backend/__pycache__
*.pyc
.env
.venv
backups
111 changes: 111 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
backend-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Install dependencies
run: uv sync --frozen 2>/dev/null || uv sync

- name: Run migrations
run: uv run python manage.py migrate --noinput

- name: Seed curriculum
run: uv run python manage.py seed_curriculum

- name: Run tests
run: uv run python manage.py test

frontend-build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Build (includes type-check)
run: npm run build

e2e-tests:
runs-on: ubuntu-latest
needs: [backend-tests, frontend-build]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install backend dependencies
run: uv sync --frozen 2>/dev/null || uv sync

- name: Prepare backend
working-directory: backend
run: |
uv run python manage.py migrate --noinput
uv run python manage.py seed_curriculum

- name: Install frontend dependencies
working-directory: frontend
run: npm ci

- name: Install Playwright browsers
working-directory: frontend
run: npx playwright install chromium --with-deps

- name: Run Playwright tests
working-directory: frontend
run: npx playwright test

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 14
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,9 @@ This starts 4 services:

The app is available at `http://localhost` (or the port set via `NGINX_PORT`).

To run migrations and seed data:
Migrations run automatically on container start via the entrypoint script. To seed curriculum data:

```bash
docker compose exec backend python manage.py migrate
docker compose exec backend python manage.py seed_curriculum
```

Expand All @@ -172,6 +171,41 @@ cd backend
uv run python manage.py test
```

## Database Backups

Back up the PostgreSQL database with the included script:

```bash
./scripts/backup-db.sh
```

Backups are saved to `./backups/` as timestamped gzipped SQL dumps (e.g. `pystarter_20260304_120000.sql.gz`).

**Automatic daily backups with cron:**

```bash
0 2 * * * cd /path/to/project && PRUNE_DAYS=30 ./scripts/backup-db.sh >> /var/log/pystarter-backup.log 2>&1
```

**Restore from backup:**

```bash
gunzip -c backups/pystarter_20260304_120000.sql.gz | docker compose exec -T db psql -U pystarter pystarter
```

## Sandbox Security

User-submitted code runs in a restricted Python sandbox (`backend/apps/executor/sandbox.py`) with these safeguards:

- **Import whitelist** — only safe standard library modules (math, random, string, collections, datetime, json, re, typing, copy, itertools, functools, textwrap, ipaddress)
- **Blocked builtins** — exec, eval, compile, open, \_\_import\_\_ (restricted), getattr, setattr, and other dangerous functions are removed or replaced
- **Replaced input()** — reads from a pre-loaded input queue instead of stdin
- **5-second timeout** — execution is killed after 5 seconds
- **Memory limit** — resource limits prevent memory exhaustion
- **Recursion limit** — set to 200 to prevent stack overflow

This is appropriate for a single-tenant training platform. For untrusted multi-tenant use, code execution should be moved to containerized isolation (e.g. gVisor, Firecracker, or a dedicated code execution service).

## License

[MIT](LICENSE)
4 changes: 4 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ ENV PATH="/app/.venv/bin:$PATH"
ENV DJANGO_SETTINGS_MODULE=config.settings.production
ENV PYTHONPATH=/app/backend

COPY backend/entrypoint.sh /app/backend/entrypoint.sh
RUN chmod +x /app/backend/entrypoint.sh

EXPOSE 8000

ENTRYPOINT ["/app/backend/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
1 change: 1 addition & 0 deletions backend/config/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@

# SSL / Cookie security
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Expand Down
25 changes: 25 additions & 0 deletions backend/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh
set -e

echo "Waiting for database..."
attempts=30
while ! python -c "
import os; os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings.production')
import django; django.setup()
from django.db import connection; connection.ensure_connection()
" 2>/dev/null; do
attempts=$((attempts - 1))
if [ "$attempts" -le 0 ]; then
echo "Database timeout after 60s"
exit 1
fi
sleep 2
done

echo "Running migrations..."
python manage.py migrate --noinput

echo "Collecting static files..."
python manage.py collectstatic --noinput

exec "$@"
12 changes: 10 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ services:
condition: service_healthy
expose:
- "8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen(urllib.request.Request('http://localhost:8000/api/v1/health/', headers={'X-Forwarded-Proto': 'https'}))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

frontend:
build:
Expand All @@ -48,8 +54,10 @@ services:
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
- frontend
backend:
condition: service_healthy
frontend:
condition: service_started

volumes:
pgdata:
25 changes: 25 additions & 0 deletions scripts/backup-db.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh
set -e

# PostgreSQL backup script for PyStarter
# Usage: ./scripts/backup-db.sh
# Optional: BACKUP_DIR=./my-backups PRUNE_DAYS=30 ./scripts/backup-db.sh

BACKUP_DIR="${BACKUP_DIR:-./backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
FILENAME="pystarter_${TIMESTAMP}.sql.gz"

mkdir -p "$BACKUP_DIR"

echo "Backing up database to ${BACKUP_DIR}/${FILENAME}..."
docker compose exec -T db pg_dump -U pystarter pystarter | gzip > "${BACKUP_DIR}/${FILENAME}"

SIZE=$(ls -lh "${BACKUP_DIR}/${FILENAME}" | awk '{print $5}')
echo "Backup complete: ${BACKUP_DIR}/${FILENAME} (${SIZE})"

# Optional: prune old backups
if [ -n "$PRUNE_DAYS" ]; then
echo "Pruning backups older than ${PRUNE_DAYS} days..."
find "$BACKUP_DIR" -name "pystarter_*.sql.gz" -mtime +"$PRUNE_DAYS" -delete
echo "Pruning complete."
fi
Loading