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
41 changes: 41 additions & 0 deletions .github/workflows/gateway.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: gateway

on:
pull_request:
paths: ["gateway/**", ".github/workflows/gateway.yml"]
push:
branches: ["main"]
paths: ["gateway/**", ".github/workflows/gateway.yml"]

defaults:
run:
working-directory: "gateway"

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy .
- run: uv run pytest tests -q

deploy:
needs: check
if: github.event_name == 'push'
runs-on: ubuntu-latest
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked --no-dev
- run: uv run --locked --no-dev modal deploy app.py
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ wheels/

# Virtual environments
.venv

# Vars
.env

# Plans
plans/**
23 changes: 23 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.8 # run `pre-commit autoupdate` to pin the latest
hooks:
- id: ruff-check
args: [--fix]
files: ^gateway/
- id: ruff-format
files: ^gateway/
- repo: local
hooks:
- id: mypy
name: mypy (gateway)
entry: uv run --directory gateway mypy .
language: system
files: ^gateway/.*\.py$
pass_filenames: false
- id: pytest
name: pytest (gateway)
entry: uv run --directory gateway pytest tests -q
language: system
files: ^gateway/
pass_filenames: false
111 changes: 111 additions & 0 deletions gateway/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import pathlib
import secrets
import time
from typing import Annotated

import modal
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates

from auth import read_session, sign_session, verify_invite
from state import get_invite, is_invite_valid

DEMOS = ["hi"]
SESSION_TTL = 28800 # 8 hrs
TEMPLATES_DIR = pathlib.Path(__file__).parent / "templates"


image = (
modal.Image.debian_slim()
.uv_sync(extra_options="--no-dev")
.add_local_python_source("auth", "state")
.add_local_dir(TEMPLATES_DIR, remote_path="/root/templates")
)
app = modal.App(
name="gateway",
image=image,
secrets=[modal.Secret.from_name("gateway")],
)

templates = Jinja2Templates(directory=TEMPLATES_DIR)

web = FastAPI()


def _expired():
return HTTPException(status_code=303, headers={"location": "/expired"})


def require_session(request: Request) -> dict:
now = int(time.time())
if (cookie := request.cookies.get("session")) is None:
raise _expired()
if (session := read_session(cookie)) is None:
raise _expired()
if (invite := get_invite(session["iid"])) is None:
raise _expired()
if not is_invite_valid(invite, now):
raise _expired()
return {"session": session, "invite": invite}


@web.get("/")
async def hello():
return PlainTextResponse("hello")


@web.get("/healthz")
async def healthz():
return {"ok": True}


@web.get("/i/{token}")
def redeem(token: str):
# verify invite
if (content := verify_invite(token)) is None:
raise _expired()

# check invite status
iid = content["iid"]
if (invite := get_invite(iid)) is None:
raise _expired()
now = int(time.time())
if not is_invite_valid(invite, now):
raise _expired()

# start session
sid = secrets.token_hex(8)
exp = min(now + SESSION_TTL, invite["expires"])
cookie = sign_session(iid, sid, exp)
r = RedirectResponse(url="/menu", status_code=303)
r.set_cookie(
key="session",
value=cookie,
httponly=True,
secure=True,
samesite="lax",
max_age=exp - now,
)
return r


@web.get("/expired")
def expired():
return PlainTextResponse("This link has expired.")


@web.get("/menu")
def menu(request: Request, session: Annotated[dict, Depends(require_session)]):
return templates.TemplateResponse(
request=request,
name="menu.html",
context={"demos": DEMOS, **session},
)


@app.function()
@modal.concurrent(max_inputs=3)
@modal.asgi_app(label="gateway")
def fastapi_app():
return web
46 changes: 46 additions & 0 deletions gateway/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import os
import time

from itsdangerous import BadSignature, URLSafeSerializer

INVITE_SALT = "invite-v1"
SESSION_SALT = "session-v1"


def _serializer(salt: str) -> URLSafeSerializer:
if SIGNING_KEY := os.getenv("SIGNING_KEY"):
return URLSafeSerializer(SIGNING_KEY, salt=salt)
raise RuntimeError("SIGNING_KEY not set")


def _verify(salt: str, token: str, now: int | None = None) -> dict | None:
auth_s = _serializer(salt)
if now is None:
now = int(time.time())
try:
contents = auth_s.loads(token)
except BadSignature:
return None
if contents.get("exp", 0) > now:
return contents
return None


def sign_invite(invite_id: str, exp_ts: int) -> str:
auth_s = _serializer(INVITE_SALT)
token = auth_s.dumps({"iid": invite_id, "exp": exp_ts})
return token


def verify_invite(token: str, now: int | None = None) -> dict | None:
return _verify(salt=INVITE_SALT, token=token, now=now)


def sign_session(invite_id: str, session_id: str, exp_ts: int) -> str:
auth_s = _serializer(SESSION_SALT)
token = auth_s.dumps({"iid": invite_id, "sid": session_id, "exp": exp_ts})
return token


def read_session(cookie: str, now: int | None = None) -> dict | None:
return _verify(salt=SESSION_SALT, token=cookie, now=now)
37 changes: 37 additions & 0 deletions gateway/invites.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import modal
from dotenv import load_dotenv

from auth import sign_invite
from state import get_invite, list_invites, new_invite, put_invite

load_dotenv()


cli = modal.App(name="gateway-invites-cli")


@cli.local_entrypoint()
def create(label: str, days: int = 1, budget_s: int = 60):
url = modal.Function.from_name("gateway", "fastapi_app").get_web_url()
iid, invite = new_invite(label=label, days=days, budget_s=budget_s)
put_invite(iid, invite)
token = sign_invite(iid, invite["expires"])
print(f"{url}/i/{token}")


@cli.local_entrypoint(name="list")
def ls():
invites = list_invites()
for invite in invites:
print(invite)


@cli.local_entrypoint()
def revoke(iid: str):
invite = get_invite(iid)
if invite is None:
print(f"{iid} invite not found.")
return
invite["revoked"] = True
put_invite(iid, invite)
print(f"{iid} invite revoked.")
29 changes: 29 additions & 0 deletions gateway/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[project]
name = "gateway"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"itsdangerous",
"jinja2",
"modal",
"python-multipart",
"python-dotenv",
]

[dependency-groups]
dev = ["pytest", "mypy", "ruff"]

[tool.uv]
package = false

[tool.ruff]
target-version = "py312"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

[tool.mypy]
python_version = "3.12"
strict = false
53 changes: 53 additions & 0 deletions gateway/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import secrets
import time

import modal

state_dict = modal.Dict.from_name("gateway-state", create_if_missing=True)

INVITE_PREFIX = "invite:"


def _key(iid: str) -> str:
return f"{INVITE_PREFIX}{iid}"


def new_invite(label: str, days: int, budget_s: int, now: int | None = None) -> tuple[str, dict]:
iid = secrets.token_hex(8)
if now is None:
now = int(time.time())
record = {
"label": label,
"created": now,
"expires": now + days * 86400,
"budget_s": budget_s,
"used": 0,
"revoked": False,
"redeemed_at": None,
"display_name": None,
}
return iid, record


def get_invite(iid: str) -> dict | None:
return state_dict.get(_key(iid))


def put_invite(iid: str, record: dict) -> None:
state_dict[_key(iid)] = record


def list_invites() -> list[tuple[str, dict]]:
invites = []
for iid, record in state_dict.items():
if iid.startswith(INVITE_PREFIX):
invites.append((iid.removeprefix(INVITE_PREFIX), record))
return invites


def is_invite_valid(invite: dict, now: int) -> bool:
if invite["revoked"]:
return False
if invite["expires"] <= now:
return False
return True
7 changes: 7 additions & 0 deletions gateway/templates/menu.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h1>Demos for {{ invite.label }}</h1>
<ul>
{% for name in demos %}
<li><a href="/demo/{{ name }}">{{ name }}</a></li>
{% endfor %}
</ul>

Empty file added gateway/tests/__init__.py
Empty file.
Loading
Loading