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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,8 @@ env/
.mypy_cache/
.coverage
htmlcov/

# Backend runtime data
backend/app.db
backend/app.db-journal
backend/uploads/
31 changes: 27 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
PROJECT_NAME="Backend API"
DEBUG=false
API_V1_PREFIX=/api/v1
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Copy to .env and fill in.

DEBUG=true

# DB
DATABASE_URL=sqlite+aiosqlite:///./app.db

# GitHub App ("Login with GitHub")
# Settings → Developer settings → GitHub Apps → your app
# - Client ID is shown on the app settings page (different from App ID).
# - Generate a new client secret and paste it below.
# - The Callback URL on the app must match GITHUB_REDIRECT_URI exactly.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=http://localhost:8000/api/v1/auth/github/callback

# Where to redirect the user after a successful login.
FRONTEND_URL=http://localhost:5173

# Session cookie. Set SESSION_COOKIE_SECURE=true behind HTTPS.
SESSION_COOKIE_NAME=dploy_session
SESSION_COOKIE_SECURE=false
SESSION_TTL_HOURS=168

# Used to sign short-lived values like the OAuth `state`. Use a long random
# string in production: `python -c "import secrets; print(secrets.token_urlsafe(48))"`
SESSION_SECRET=dev-only-change-me
45 changes: 45 additions & 0 deletions backend/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.PHONY: help install dev run test lint fmt fix clean reset-db

UV ?= uv
APP ?= app/main.py
HOST ?= 0.0.0.0
PORT ?= 8000

help:
@echo "Targets:"
@echo " install Install/sync dependencies via uv"
@echo " dev Run FastAPI with hot reload (HOST=$(HOST) PORT=$(PORT))"
@echo " run Run FastAPI in production mode"
@echo " test Run pytest"
@echo " lint Run ruff check"
@echo " fmt Run ruff format"
@echo " fix Run ruff check --fix"
@echo " reset-db Delete the local SQLite DB"
@echo " clean Remove caches and the local DB"

install:
$(UV) sync

dev:
$(UV) run fastapi dev $(APP) --host $(HOST) --port $(PORT)

run:
$(UV) run fastapi run $(APP) --host $(HOST) --port $(PORT)

test:
$(UV) run pytest

lint:
$(UV) run ruff check .

fmt:
$(UV) run ruff format .

fix:
$(UV) run ruff check . --fix

reset-db:
rm -f app.db app.db-journal

clean: reset-db
rm -rf .pytest_cache .ruff_cache .mypy_cache **/__pycache__
31 changes: 31 additions & 0 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Annotated

from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import get_settings
from app.db.session import get_session
from app.models.user import User
from app.services.auth import get_session_user

SessionDep = Annotated[AsyncSession, Depends(get_session)]


async def _resolve_current_user(request: Request, db: SessionDep) -> User:
settings = get_settings()
token = request.cookies.get(settings.session_cookie_name)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user = await get_session_user(db, token)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
)
return user


CurrentUser = Annotated[User, Depends(_resolve_current_user)]
5 changes: 4 additions & 1 deletion backend/app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from fastapi import APIRouter

from app.api.routes import health
from app.api.routes import auth, deployments, health, uploads

api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router)
api_router.include_router(uploads.router)
api_router.include_router(deployments.router)
89 changes: 89 additions & 0 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse

from app.api.deps import CurrentUser, SessionDep
from app.core.config import get_settings
from app.schemas.user import UserRead
from app.services import github_oauth, oauth_state
from app.services.auth import create_session, delete_session, upsert_user

router = APIRouter(prefix="/auth", tags=["auth"])


@router.get("/github/login")
async def github_login() -> RedirectResponse:
"""Kick off the GitHub OAuth flow."""
settings = get_settings()
if not settings.github_client_id:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GitHub OAuth is not configured",
)

return RedirectResponse(
url=github_oauth.build_authorize_url(oauth_state.issue_state()),
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)


@router.get("/github/callback")
async def github_callback(
db: SessionDep,
code: str | None = None,
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
settings = get_settings()
if error:
raise HTTPException(status_code=400, detail=f"GitHub error: {error}")
if not code or not state:
raise HTTPException(status_code=400, detail="Missing code or state")
if not oauth_state.verify_state(state):
raise HTTPException(status_code=400, detail="Invalid OAuth state")

token = await github_oauth.exchange_code_for_token(code)
gh_user = await github_oauth.fetch_user(token.access_token)

user = await upsert_user(db, gh_user, token)
session = await create_session(db, user)
await db.commit()

redirect = RedirectResponse(
url=settings.frontend_url,
status_code=status.HTTP_303_SEE_OTHER,
)
redirect.set_cookie(
settings.session_cookie_name,
session.token,
max_age=settings.session_ttl_hours * 3600,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
path="/",
)
return redirect


@router.get("/me", response_model=UserRead)
async def me(current_user: CurrentUser) -> UserRead:
return UserRead.model_validate(current_user)


@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request, response: Response, db: SessionDep) -> Response:
settings = get_settings()
token = request.cookies.get(settings.session_cookie_name)
if token:
await delete_session(db, token)
await db.commit()
response.delete_cookie(settings.session_cookie_name, path="/")
response.status_code = status.HTTP_204_NO_CONTENT
return response


@router.get("/login", include_in_schema=False)
async def login_alias() -> RedirectResponse:
return RedirectResponse(
url="/api/v1/auth/github/login",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
62 changes: 62 additions & 0 deletions backend/app/api/routes/deployments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import func, select

from app.api.deps import CurrentUser, SessionDep
from app.models.deployment import Deployment
from app.schemas.deployment import DeploymentCreate, DeploymentList, DeploymentRead

router = APIRouter(prefix="/deployments", tags=["deployments"])


@router.post(
"",
response_model=DeploymentRead,
status_code=status.HTTP_201_CREATED,
)
async def create_deployment(
payload: DeploymentCreate,
session: SessionDep,
current_user: CurrentUser,
) -> Deployment:
deployment = Deployment(
user_id=current_user.id,
name=payload.name,
github_url=payload.github_url,
upload_id=payload.upload_id,
)
session.add(deployment)
await session.commit()
await session.refresh(deployment)

# TODO: enqueue Agent #1 (analyze) -> Agent #2 (expose ports) here.
return deployment


@router.get("", response_model=DeploymentList)
async def list_deployments(
session: SessionDep,
current_user: CurrentUser,
limit: int = 50,
offset: int = 0,
) -> DeploymentList:
base = select(Deployment).where(Deployment.user_id == current_user.id)
total = await session.scalar(
select(func.count()).select_from(base.subquery()),
)
result = await session.scalars(
base.order_by(Deployment.created_at.desc()).limit(limit).offset(offset),
)
items = [DeploymentRead.model_validate(d) for d in result.all()]
return DeploymentList(items=items, total=total or 0)


@router.get("/{deployment_id}", response_model=DeploymentRead)
async def get_deployment(
deployment_id: str,
session: SessionDep,
current_user: CurrentUser,
) -> Deployment:
deployment = await session.get(Deployment, deployment_id)
if deployment is None or deployment.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Deployment not found")
return deployment
37 changes: 37 additions & 0 deletions backend/app/api/routes/uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import uuid
from pathlib import Path
from typing import Annotated

from fastapi import APIRouter, File, UploadFile

from app.schemas.deployment import UploadResponse

router = APIRouter(prefix="/uploads", tags=["uploads"])

# Local-disk staging area. Swap for S3 later.
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)


@router.post("", response_model=UploadResponse)
async def upload_code(file: Annotated[UploadFile, File(...)]) -> UploadResponse:
"""Accept a tarball / zip of the user's project directory.

The CLI bundles the current folder, POSTs it here, and uses the returned
`upload_id` when creating a deployment.
"""
upload_id = uuid.uuid4().hex
suffix = Path(file.filename or "").suffix
dest = UPLOAD_DIR / f"{upload_id}{suffix}"

size = 0
with dest.open("wb") as out:
while chunk := await file.read(1024 * 1024):
out.write(chunk)
size += len(chunk)

return UploadResponse(
upload_id=upload_id,
filename=file.filename or dest.name,
size=size,
)
20 changes: 20 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ class Settings(BaseSettings):
"http://localhost:3000",
]

database_url: str = "sqlite+aiosqlite:///./app.db"
db_echo: bool = False

# GitHub App (user-to-server "Login with GitHub" flow).
github_client_id: str = ""
github_client_secret: str = ""
github_redirect_uri: str = "http://localhost:8000/api/v1/auth/github/callback"

# Where to send the user after a successful login.
frontend_url: str = "http://localhost:5173"

# Session cookie.
session_cookie_name: str = "dploy_session"
session_cookie_secure: bool = False
session_ttl_hours: int = 24 * 7

# Used to HMAC-sign short-lived values like the OAuth `state` param. Set
# this to a long random string in production.
session_secret: str = "dev-only-change-me"


@lru_cache
def get_settings() -> Settings:
Expand Down
4 changes: 4 additions & 0 deletions backend/app/db/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from app.db.base import Base
from app.db.session import get_session, init_db

__all__ = ["Base", "get_session", "init_db"]
24 changes: 24 additions & 0 deletions backend/app/db/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from datetime import UTC, datetime

from sqlalchemy import DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


def _utcnow() -> datetime:
return datetime.now(UTC)


class Base(DeclarativeBase):
"""Shared declarative base with created/updated timestamps."""

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=_utcnow,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=_utcnow,
onupdate=_utcnow,
nullable=False,
)
Loading