Implement v1 URL shortener (Bearer API, public 302, landing, CI) (#2)
## Summary - Standalone Django 6 shortener: Bearer `/api/links/` (create/list/detail/disable) and public `GET /<code>` 302 - Host split, target-host allowlist, named rotatable tokens; `short_url` from `PUBLIC_SHORT_URL` - Landing page, DEBUG-only `/debug/` mint form, Django admin - Docker/compose (host **8005**), Gitea CI like monica_site (PR tests, beta on merge, prod button) - Caller contract in `API.md` Closes #1. Infra follow-up: [server-infra#22](ai_ml_operations/server-infra#22). ## Test plan - [ ] `cd site && uv run python manage.py test` - [ ] `docker compose up --build` → http://127.0.0.1:8005/ - [ ] `POST /api/links/` with `Bearer monica:dev-only-token` → 201 - [ ] `GET /<code>` → 302 to allowlisted https URL - [ ] No Bearer → 401; non-allowlisted host → 400 - [ ] `/debug/` only when `DEBUG=true` Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""ASGI config for shortener."""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Load environment-specific Django settings based on DJANGO_ENV."""
|
||||
|
||||
import os
|
||||
|
||||
_environment = os.environ.get("DJANGO_ENV", "dev").lower()
|
||||
|
||||
if _environment == "prod":
|
||||
from .prod import * # noqa: F403
|
||||
elif _environment == "beta":
|
||||
from .beta import * # noqa: F403
|
||||
else:
|
||||
from .dev import * # noqa: F403
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Shared Django settings for all environments."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def env(key: str, default: str | None = None) -> str | None:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def env_bool(key: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def env_list(key: str, default: str = "") -> list[str]:
|
||||
value = os.environ.get(key, default)
|
||||
if not value:
|
||||
return []
|
||||
value = value.strip()
|
||||
if value.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def env_int(key: str, default: int) -> int:
|
||||
value = env(key)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return int(value)
|
||||
|
||||
|
||||
def parse_api_tokens(raw: str) -> list[tuple[str, str]]:
|
||||
"""Parse ``name:secret,name:secret`` into ``[(name, secret), ...]``."""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
if not raw:
|
||||
return tokens
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part or ":" not in part:
|
||||
continue
|
||||
name, secret = part.split(":", 1)
|
||||
name, secret = name.strip(), secret.strip()
|
||||
if name and secret:
|
||||
tokens.append((name, secret))
|
||||
return tokens
|
||||
|
||||
|
||||
def database_config() -> dict:
|
||||
database_url = env("DATABASE_URL")
|
||||
if database_url:
|
||||
parsed = urlparse(database_url)
|
||||
return {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": parsed.path.lstrip("/"),
|
||||
"USER": parsed.username or "",
|
||||
"PASSWORD": parsed.password or "",
|
||||
"HOST": parsed.hostname or "",
|
||||
"PORT": str(parsed.port or 5432),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SECRET_KEY = env(
|
||||
"DJANGO_SECRET_KEY",
|
||||
"django-insecure-dev-only-change-me-before-production",
|
||||
)
|
||||
|
||||
DEBUG = env_bool("DJANGO_DEBUG", False)
|
||||
|
||||
allowed_hosts = env_list(
|
||||
"DJANGO_ALLOWED_HOSTS",
|
||||
"localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener,go.mkdrealtor.com",
|
||||
)
|
||||
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"core.apps.CoreConfig",
|
||||
"links.apps.LinksConfig",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"links.middleware.HostSplitMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "shortener.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"core.context_processors.branding",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "shortener.wsgi.application"
|
||||
|
||||
DATABASES = database_config()
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "America/Chicago"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# --- Shortener ---
|
||||
SHORT_DOMAIN = env("SHORT_DOMAIN", "localhost:8000") or "localhost:8000"
|
||||
PUBLIC_SHORT_URL = (env("PUBLIC_SHORT_URL", "https://go.mkdrealtor.com") or "").rstrip(
|
||||
"/"
|
||||
)
|
||||
SHORT_PUBLIC_HOSTS = env_list("SHORT_PUBLIC_HOSTS", SHORT_DOMAIN.split(":")[0])
|
||||
SHORT_API_HOSTS = env_list(
|
||||
"SHORT_API_HOSTS",
|
||||
"localhost,127.0.0.1,0.0.0.0,testserver,web,url-shortener",
|
||||
)
|
||||
# Django admin — local/dev only. Never put the public API hostname here.
|
||||
SHORT_ADMIN_HOSTS = env_list("SHORT_ADMIN_HOSTS", "localhost,127.0.0.1")
|
||||
SHORTENER_API_TOKENS = parse_api_tokens(env("SHORTENER_API_TOKENS", "") or "")
|
||||
SHORT_ALLOWED_HOSTS = env_list(
|
||||
"SHORT_ALLOWED_HOSTS", "mkdrealtor.com,aimloperations.com"
|
||||
)
|
||||
SHORT_CODE_LENGTH = env_int("SHORT_CODE_LENGTH", 6)
|
||||
CLICK_IP_PEPPER = env("CLICK_IP_PEPPER", "") or ""
|
||||
CODE_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||
SITE_NAME = env("SITE_NAME", "URL Shortening Service") or "URL Shortening Service"
|
||||
CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations") or "AI ML Operations"
|
||||
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com") or "https://aimloperations.com"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Beta/staging settings."""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .logging import build_logging_config, logging_level_for_env
|
||||
|
||||
DEBUG = env_bool("DJANGO_DEBUG", False) # noqa: F405
|
||||
|
||||
if DEBUG:
|
||||
import warnings
|
||||
|
||||
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1)
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
|
||||
LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Development settings."""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .logging import build_logging_config, logging_level_for_env
|
||||
|
||||
DEBUG = True
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
LOGGING = build_logging_config(logging_level_for_env("dev"), "dev")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Environment-specific logging configuration."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def build_logging_config(level: str, environment: str) -> dict:
|
||||
"""Return a Django LOGGING dict for the given level and environment name."""
|
||||
return {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": (
|
||||
f"{{levelname}} {{asctime}} {{name}} {{filename}}:{{lineno}} "
|
||||
f"{{process:d}} {{thread:d}} [env={environment}] {{message}}"
|
||||
),
|
||||
"style": "{",
|
||||
},
|
||||
"simple": {
|
||||
"format": (
|
||||
f"{{levelname}} [env={environment}] "
|
||||
f"{{filename}}:{{lineno}} {{message}}"
|
||||
),
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"strip_authorization": {
|
||||
"()": "shortener.settings.logging.StripAuthorizationFilter",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose" if environment == "dev" else "simple",
|
||||
"filters": ["strip_authorization"],
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": level,
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["console"],
|
||||
"level": level,
|
||||
"propagate": False,
|
||||
},
|
||||
"django.request": {
|
||||
"handlers": ["console"],
|
||||
"level": "ERROR" if environment == "prod" else level,
|
||||
"propagate": False,
|
||||
},
|
||||
"django.server": {
|
||||
"handlers": ["console"],
|
||||
"level": level,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class StripAuthorizationFilter:
|
||||
"""Drop log records that appear to contain an Authorization header."""
|
||||
|
||||
def filter(self, record) -> bool:
|
||||
message = record.getMessage()
|
||||
if "authorization" in message.lower() and "bearer" in message.lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def logging_level_for_env(environment: str) -> str:
|
||||
override = os.environ.get("DJANGO_LOG_LEVEL")
|
||||
if override:
|
||||
return override.upper()
|
||||
|
||||
if environment == "dev":
|
||||
return "DEBUG"
|
||||
if environment == "beta":
|
||||
return "INFO"
|
||||
return "WARNING"
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Production settings."""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .logging import build_logging_config, logging_level_for_env
|
||||
|
||||
DEBUG = False
|
||||
|
||||
if not env("DJANGO_SECRET_KEY"): # noqa: F405
|
||||
raise ValueError("DJANGO_SECRET_KEY must be set in production.")
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""URL configuration for shortener."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
from core.views import healthz, landing
|
||||
from links.debug_views import debug_create
|
||||
from links.views import redirect_view
|
||||
|
||||
urlpatterns = [
|
||||
path("", landing, name="landing"),
|
||||
path("healthz/", healthz, name="healthz"),
|
||||
path("debug/", debug_create, name="debug-create"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/links/", include("links.urls")),
|
||||
path("<str:code>", redirect_view, name="redirect"),
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""WSGI config for shortener."""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
Reference in New Issue
Block a user