Skip to content

Commit

Permalink
Merge pull request #4 from jsickcodes/edition-url-rewrites
Browse files Browse the repository at this point in the history
Add logic for mapping paths to S3 keys
  • Loading branch information
jonathansick authored Dec 29, 2021
2 parents b85c027 + 3e41d04 commit 80a5d43
Show file tree
Hide file tree
Showing 11 changed files with 206 additions and 25 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ jobs:
with:
python-version: ${{ matrix.python }}

- name: Install stable virtualenv # https://github.com/pre-commit/action/issues/135
run: pip install -U virtualenv==20.10.0

- name: Run pre-commit
uses: pre-commit/[email protected]

Expand Down
2 changes: 1 addition & 1 deletion manifests/base/auth-configmap.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: "ltdproxy-auth"
name: "ltd-proxy-auth"
labels:
app.kubernetes.io/name: "ltd-proxy"
data:
Expand Down
8 changes: 4 additions & 4 deletions manifests/base/configmap.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: "ltdproxy"
name: "ltd-proxy"
labels:
app.kubernetes.io/name: "ltd-proxy"
data:
# These configurations are injected as environment variables into the
# app container.
SAFIR_NAME: "ltdproxy"
SAFIR_LOGGER: "ltdproxy"
SAFIR_NAME: "ltd-proxy"
SAFIR_LOGGER: "ltd-proxy"
SAFIR_LOG_LEVEL: "INFO"
SAFIR_PROFILE: "production"
LTDPROXY_AUTH_CONFIG: "/opt/ltdproxy/auth/authrules.yaml"
LTDPROXY_AUTH_CONFIG: "/opt/ltd-proxy/auth/authrules.yaml"
LTDPROXY_PATH_PREFIX: "/"
LTDPROXY_S3_BUCKET: ""
LTDPROXY_S3_PREFIX: ""
Expand Down
8 changes: 4 additions & 4 deletions manifests/base/deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: "ltdproxy"
name: "ltd-proxy"
labels:
app.kubernetes.io/name: "ltd-proxy"
spec:
Expand All @@ -25,7 +25,7 @@ spec:
name: "app"
envFrom:
- configMapRef:
name: "ltdproxy"
name: "ltd-proxy"
securityContext:
allowPrivilegeEscalation: false
capabilities:
Expand All @@ -34,12 +34,12 @@ spec:
readOnlyRootFilesystem: true
volumeMounts:
- name: "auth-config"
mountPath: "/opt/ltdproxy/auth/"
mountPath: "/opt/ltd-proxy/auth/"
readOnly: true
volumes:
- name: "auth-config"
configMap:
name: "ltdproxy-auth"
name: "ltd-proxy-auth"
securityContext:
runAsNonRoot: true
runAsUser: 1000
Expand Down
2 changes: 1 addition & 1 deletion manifests/base/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ kind: Kustomization

images:
- name: "ghcr.io/jsickcodes/ltd-proxy"
newTag: 0.0.0
newTag: 0.1.0

resources:
- configmap.yaml
Expand Down
6 changes: 3 additions & 3 deletions manifests/base/service.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: "ltdproxy"
name: "ltd-proxy"
labels:
app.kubernetes.io/name: "ltd-proxy"
spec:
ports:
- name: "ltdproxy-http"
- name: "ltd-proxy-http"
protocol: "TCP"
port: 8080
targetPort: "app"
selector:
app.kubernetes.io/name: "ltdproxy"
app.kubernetes.io/name: "ltd-proxy"
27 changes: 21 additions & 6 deletions src/ltdproxy/githubauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import yaml
from authlib.integrations.starlette_client import OAuth
from pydantic import BaseModel
from structlog import get_logger

from ltdproxy.config import config

Expand All @@ -42,6 +43,8 @@
)
"""Type alias from the authlib GitHub OAuth client."""

logger = get_logger(config.logger_name)


class GitHubOAuth:
"""This class maintains an OAuth instance that is registered for GitHub
Expand Down Expand Up @@ -90,7 +93,8 @@ async def set_serialized_github_memberships(
# These orgs and teams are mentioned in the GitHub Auth configuration,
# and therefore are ones to pay attention to in the cookie.
relevant_orgs = github_auth.relevant_orgs
relevant_teams = github_auth.relevant_teams

logger.debug("Relevant orgs", orgs=relevant_orgs)

github_client = gidgethub.httpx.GitHubAPI(
http_client, "ltd-proxy", oauth_token=github_token
Expand All @@ -106,11 +110,13 @@ async def set_serialized_github_memberships(
user_teams: List[Tuple[str, str]] = []
async for team in github_client.getiter("/user/teams"):
team_id = (team["organization"]["login"], team["name"])
if team_id in relevant_teams:
if team_id[0] in relevant_orgs:
logger.debug("Found relevant team", team=team)
user_teams.append(team_id)

# Serialize memberships to JSON to pack inside the session cookie
memberships = json.dumps({"orgs": user_orgs, "teams": user_teams})
logger.debug("GitHub user memberships", orgs=user_orgs, teams=user_teams)
session["github_memberships"] = memberships


Expand Down Expand Up @@ -147,6 +153,11 @@ class PathRule(BaseModel):
def path_matches(self, url_path: str) -> bool:
"""Test if a URL path matches the rule's patten."""
if self.pattern.match(url_path):
logger.debug(
"Path matches PathRule",
pattern=self.pattern,
url_path=url_path,
)
return True
else:
return False
Expand All @@ -172,6 +183,12 @@ def is_user_authorized(
return True

# no matches
logger.debug(
"No authorization match",
pattern=self.pattern,
user_orgs=user_orgs,
user_teams=user_teams,
)
return False


Expand Down Expand Up @@ -257,13 +274,11 @@ def relevant_orgs(self) -> Set[str]:
all_orgs: Set[str] = set()

for github_group in self.default:
if not github_group.is_team:
all_orgs.add(github_group.org)
all_orgs.add(github_group.org)

for path_rule in self.paths:
for github_group in path_rule.authorized:
if not github_group.is_team:
all_orgs.add(github_group.org)
all_orgs.add(github_group.org)

return all_orgs

Expand Down
48 changes: 42 additions & 6 deletions src/ltdproxy/handlers/external.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Handlers for the app's external root, ``/ltdproxy/``."""

import posixpath
from typing import Optional, Union
from urllib.parse import urlencode, urlparse

Expand All @@ -12,6 +13,7 @@
from starlette.requests import Request
from starlette.responses import (
HTMLResponse,
PlainTextResponse,
RedirectResponse,
StreamingResponse,
)
Expand All @@ -27,13 +29,19 @@
set_serialized_github_memberships,
)
from ltdproxy.s3 import Bucket, bucket_dependency
from ltdproxy.urlmap import map_s3_path

__all__ = ["get_s3", "external_router"]

external_router = APIRouter()
"""FastAPI router for all external handlers."""


@external_router.get("/", name="homepage")
async def get_homepage() -> PlainTextResponse:
return PlainTextResponse("OK", status_code=200)


@external_router.get("/auth", name="get_oauth_callback")
async def get_oauth_callback(
ref: Optional[str],
Expand Down Expand Up @@ -147,20 +155,48 @@ async def get_s3(

elif github_auth_result == AuthResult.authorized:
# User is authorized; stream from S3.
if path == "" or path.endswith("/"):
# redwrite "*/" as "*/index.html" for static sites in S3
bucket_path = f"{config.s3_bucket_prefix}{path}index.html"
else:
bucket_path = f"{config.s3_bucket_prefix}{path}"
bucket_path = map_s3_path(config.s3_bucket_prefix, path)
logger.debug(
"computed bucket path",
bucket_path=bucket_path,
request_url=str(request.url),
)
stream = await bucket.stream_object(http_client, bucket_path)
if stream.status_code == 404:
raise HTTPException(status_code=404, detail="Does not exist.")
if not path.endswith("/") and posixpath.splitext(path)[1] == "":
# try a redirect
parsed_url = urlparse(str(request.url))
parsed_url = parsed_url._replace(path=f"{parsed_url.path}/")
return RedirectResponse(url=parsed_url.geturl())
else:
raise HTTPException(status_code=404, detail="Does not exist.")
logger.debug("stream headers", headers=stream.headers)
response_headers = {
"Content-type": stream.headers["Content-type"],
"Content-length": stream.headers["Content-length"],
"Etag": stream.headers["Etag"],
}
# FIXME hack to override content-type headers
if bucket_path.endswith(".html"):
logger.debug("is html")
response_headers["Content-type"] = "text/html"
elif bucket_path.endswith(".css"):
logger.debug("is css")
response_headers["Content-type"] = "text/css"
elif bucket_path.endswith(".js"):
logger.debug("is js")
response_headers["Content-type"] = "application/javascript"
elif bucket_path.endswith(".pdf"):
logger.debug("is pdf")
response_headers["Content-type"] = "application/pdf"
elif bucket_path.endswith(".png"):
logger.debug("is png")
response_headers["Content-type"] = "image/png"
else:
logger.debug("did not change response content-type")

logger.debug("response headers", headers=response_headers)

return StreamingResponse(
stream.aiter_raw(),
background=BackgroundTask(stream.aclose),
Expand Down
8 changes: 8 additions & 0 deletions src/ltdproxy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
called.
"""

import structlog
from fastapi import FastAPI
from safir.dependencies.http_client import http_client_dependency
from safir.logging import configure_logging
from safir.metadata import get_metadata
from safir.middleware.x_forwarded import XForwardedMiddleware
from starlette.middleware.sessions import SessionMiddleware

Expand All @@ -36,6 +38,12 @@

@app.on_event("startup")
async def startup_event() -> None:
logger = structlog.get_logger(config.logger_name)
metadata = get_metadata(
package_name="ltd-proxy",
application_name=config.name,
)
logger.info("Starting up", version=metadata.version)
app.add_middleware(XForwardedMiddleware)


Expand Down
40 changes: 40 additions & 0 deletions src/ltdproxy/urlmap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Domain model for mapping a request URL to a resource in the S3 bucket."""

from __future__ import annotations

__all__ = ["map_s3_path"]


def map_s3_path(bucket_prefix: str, request_path: str) -> str:
"""Map a request URL to an S3 bucket key."""
# decompose the path into the project and whether it is a /v/ edition or
# not
parts = request_path.split("/")
project_name = parts[0].lower()

if (len(parts) >= 3) and parts[1].lower() == "v":
edition_name = parts[2]
edition_path = "/".join(parts[3:])
else:
edition_name = "__main" # default edition
edition_path = "/".join(parts[1:])

# if edition_path == "" or edition_path.endswith("/"):
if request_path.endswith("/"):
edition_path = f"{edition_path}index.html"

if bucket_prefix == "":
path_parts = [project_name, "v", edition_name, edition_path]
else:
path_parts = [
bucket_prefix,
project_name,
"v",
edition_name,
edition_path,
]

bucket_path = "/".join(path_parts)
bucket_path = bucket_path.rstrip("/") # happens if edition_path is ""

return bucket_path
Loading

0 comments on commit 80a5d43

Please sign in to comment.