Skip to content
Draft
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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ JWT_AUDIENCE=
ACCESS_TOKEN_TTL_SECONDS=3600
REFRESH_TOKEN_TTL_SECONDS=2592000
COOKIE_SECURE="false" # false: for local dev, true: for production
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n.....\n-----END PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n.....\n-----END PUBLIC KEY-----"
# Leave BOTH empty to use JWT_SECRET (HS256) instead — non-empty
# placeholder values here would make the app pick RS256 and crash on the
# first login with an unparseable key.
JWT_PRIVATE_KEY=""
JWT_PUBLIC_KEY=""
JWT_SECRET=""

# To generate these private key run this commands:
Expand Down
27 changes: 26 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ concurrency:
cancel-in-progress: false

jobs:
# Attach the self-host compose to the release so
# releases/latest/download/docker-compose.yml always serves the file
# matching the newest image.
assets:
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Upload self-host compose to release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber
Comment on lines +42 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The workflow assumes gh is available on the runner, which might break if GitHub changes the default image.

The assets job calls gh release upload and assumes the GitHub CLI is preinstalled on ubuntu-latest. To avoid brittle, image-dependent failures, consider adding an explicit install/setup step for gh or pinning the runner image to one that guarantees gh is available.

Comment on lines +42 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pass tag_name through an environment variable to prevent template injection.

github.event.release.tag_name is interpolated directly into a run command via ${{ }}. While Git tag names are character-restricted (no spaces, semicolons, or backticks), this is a confirmed template-injection finding from zizmor and violates the GitHub Actions security best practice of never expanding ${{ }} expressions inside run steps. The fix is trivial — move the value into an environment variable and reference it by name.

🔒️ Proposed fix: use env var instead of direct interpolation
       - name: Upload self-host compose to release
         env:
           GH_TOKEN: ${{ github.token }}
+          RELEASE_TAG: ${{ github.event.release.tag_name }}
-        run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber
+        run: gh release upload "$RELEASE_TAG" selfhost/docker-compose.yml --clobber
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Upload self-host compose to release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber
- name: Upload self-host compose to release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$RELEASE_TAG" selfhost/docker-compose.yml --clobber
🧰 Tools
🪛 zizmor (1.26.1)

[error] 45-45: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 42 - 45, Update the “Upload
self-host compose to release” step to pass the release tag through its env
configuration using a descriptive variable, then reference that shell
environment variable in the gh release upload command instead of interpolating
github.event.release.tag_name directly in run.

Source: Linters/SAST tools


build:
# releases always build; manual dispatch skips the build when an existing
# image tag is supplied (rollback path)
Expand All @@ -42,6 +61,12 @@ jobs:
with:
persist-credentials: false

# QEMU enables the arm64 leg of the multi-arch build (Apple Silicon,
# Raspberry Pi, Graviton/ARM VPS self-hosters). Wheels are prebuilt
# for both arches so emulation cost is mostly I/O.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

Expand Down Expand Up @@ -76,7 +101,7 @@ jobs:
APP_VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
platforms: linux/amd64,linux/arm64

deploy:
needs: build
Expand Down
50 changes: 36 additions & 14 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import os
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

Expand Down Expand Up @@ -65,6 +66,33 @@
_DOCS_URL = "https://docs.spoo.me"


def _ensure_jwt_secret(settings: AppSettings) -> None:
"""Guarantee a usable HS256 secret when RS256 keys are absent.

pyjwt refuses an empty HMAC key, so with no JWT config at all every
register/login would 500. An ephemeral random secret keeps zero-config
auth working (policy: everything optional); the cost is sessions not
surviving restarts until the operator sets a real secret.
"""
if not settings.jwt or settings.jwt.use_rs256:
return
if not settings.jwt.jwt_secret:
settings.jwt.jwt_secret = secrets.token_hex(32)
log.warning(
"jwt_secret_autogenerated",
detail="No JWT keys or JWT_SECRET configured — generated an "
"ephemeral secret. Sessions will not survive restarts and "
"multi-worker deployments will reject each other's tokens. "
"Set JWT_SECRET (openssl rand -hex 32) or RS256 keys.",
)
elif len(settings.jwt.jwt_secret) < 32:
log.warning(
"jwt_secret_weak",
detail="JWT_SECRET is shorter than 32 characters — consider "
"using RS256 keys or a longer secret.",
)


def create_app(settings: AppSettings | None = None) -> FastAPI:
"""Create and return a fully configured FastAPI application."""
if settings is None:
Expand Down Expand Up @@ -178,18 +206,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"both are required for RS256. Falling back to HS256.",
)

if settings.jwt and not settings.jwt.use_rs256:
if not settings.jwt.jwt_secret:
log.warning(
"jwt_config_insecure",
detail="RS256 keys not set and JWT_SECRET is empty — tokens can be forged. "
"Set JWT_PRIVATE_KEY + JWT_PUBLIC_KEY or a strong JWT_SECRET.",
)
elif len(settings.jwt.jwt_secret) < 32:
log.warning(
"jwt_secret_weak",
detail="JWT_SECRET is shorter than 32 characters — consider using RS256 keys or a longer secret.",
)
_ensure_jwt_secret(settings)

yield

Expand Down Expand Up @@ -252,8 +269,13 @@ async def docs(request: Request):
app.add_middleware(
MaxContentLengthMiddleware, max_content_length=settings.max_content_length
)
# 5. Tenant resolution — populates request.state.tenant from Host
app.add_middleware(TenantMiddleware)
# 5. Tenant resolution — populates request.state.tenant from Host.
# With custom domains off (self-host default) unknown hosts serve the
# full app instead of the SaaS sitewide 404.
app.add_middleware(
TenantMiddleware,
custom_domains_enabled=settings.custom_domains.enabled,
)
# 6. Request logging — innermost, logs all requests with request_id
app.add_middleware(RequestLoggingMiddleware)

Expand Down
35 changes: 27 additions & 8 deletions dockerfile
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
# ── Builder: resolve and install dependencies only ──────────────────────
FROM python:3.14-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

# Dependency layer keyed on the lockfile inputs alone — code changes never
# invalidate it, so rebuilds after app edits skip the entire install.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-cache --compile-bytecode


# ── Runtime: venv + code, no build machinery, non-root ──────────────────
FROM python:3.14-slim

# Injected by CI: release version (2.1.0) or short sha for edge builds
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
# UV_NO_SYNC: `uv run` entrypoints must never mutate the baked venv at
# container start (the dev-dependency group isn't installed, and a sync
# would try to pull it).
ENV APP_VERSION=${APP_VERSION} \
UV_NO_SYNC=1 \
PATH="/app/.venv/bin:${PATH}"

# Install curl for healthchecks (10MB) and clean up apt cache
# curl for healthchecks (10MB) and clean up apt cache
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& useradd --uid 1000 --create-home appuser

# Install uv.
# uv stays available so `uv run ...` compose commands keep working
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Copy the application into the container.
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY . /app/

# Install the application dependencies.
WORKDIR /app
RUN uv sync --frozen --no-cache
USER appuser

CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
13 changes: 13 additions & 0 deletions middleware/tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,19 @@ class TenantMiddleware(BaseHTTPMiddleware):
On custom tenants additionally enforces the allowlist routing policy
documented at the top of this module and stamps the noindex header on
every response.

``custom_domains_enabled=False`` (self-host default) relaxes exactly one
rule: an unknown host serves the full app surface instead of a sitewide
404. Self-hosters reach their instance through IPs, internal DNS names,
or domains the app was never told about — rejecting unknown hosts is a
SaaS-mode concern. Existing custom-domain rows keep resolving either
way, so flipping the switch never breaks live redirects.
"""

def __init__(self, app, *, custom_domains_enabled: bool = True) -> None:
super().__init__(app)
self._custom_domains_enabled = custom_domains_enabled

async def dispatch(self, request: Request, call_next) -> Response:
resolver: TenantResolver | None = getattr(
request.app.state, "tenant_resolver", None
Expand All @@ -184,6 +195,8 @@ async def dispatch(self, request: Request, call_next) -> Response:
request.state.tenant = tenant

if tenant is None:
if not self._custom_domains_enabled:
return await call_next(request)
log.info("tenant_unknown_host", host=host)
return _tenant_not_found(request, tenant=None)

Expand Down
120 changes: 120 additions & 0 deletions selfhost/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# spoo.me — self-hosted
#
# Quick start (no clone, no build, no .env required):
# wget https://github.com/spoo-me/spoo/releases/latest/download/docker-compose.yml
# docker compose up -d
# → http://localhost:8000
#
# Upgrade:
# docker compose pull && docker compose up -d
#
# Pin a version (recommended for production): create a .env file next to
# this compose with SPOO_VERSION=2.0.2 — or export it in your shell.
# Every ${VAR:-default} below can be overridden the same way; the file
# itself never needs editing.
#
# Everything optional is off by default and the app degrades gracefully:
# no OAuth → no social login, no Zepto token → no emails, no hCaptcha →
# captcha checks pass, no Sentry → no tracking. MongoDB is the only hard
# requirement, and it's bundled below.

name: spoo

services:
app:
image: ghcr.io/spoo-me/spoo:${SPOO_VERSION:-latest}
restart: unless-stopped
ports:
- "${SPOO_PORT:-8000}:8000"
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
environment:
# ── Core ────────────────────────────────────────────────────────────
MONGODB_URI: ${MONGODB_URI:-mongodb://mongo:27017}
REDIS_URI: ${REDIS_URI:-redis://redis:6379/0}
# Set to the public URL your instance is reached at (e.g.
# https://s.example.com) — it's used to build the short links you
# hand out.
APP_URL: ${APP_URL:-http://localhost:8000}
ENV: production
LOG_FORMAT: ${LOG_FORMAT:-console}

# ── Secrets ─────────────────────────────────────────────────────────
# The app boots and shortens URLs without these, but account tokens
# are only tamper-proof once they're set. Generate each with:
# openssl rand -hex 32
SECRET_KEY: ${SECRET_KEY:-}
JWT_SECRET: ${JWT_SECRET:-}
# Login cookies require HTTPS when true. Flip to true once you're
# behind a TLS-terminating reverse proxy.
COOKIE_SECURE: ${COOKIE_SECURE:-false}

# ── Optional integrations (empty = feature off) ─────────────────────
# Social login — see https://docs.spoo.me/self-hosting/setting-up-authentication
GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}
GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}
GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
GITHUB_OAUTH_REDIRECT_URI: ${GITHUB_OAUTH_REDIRECT_URI:-}
DISCORD_OAUTH_CLIENT_ID: ${DISCORD_OAUTH_CLIENT_ID:-}
DISCORD_OAUTH_CLIENT_SECRET: ${DISCORD_OAUTH_CLIENT_SECRET:-}
DISCORD_OAUTH_REDIRECT_URI: ${DISCORD_OAUTH_REDIRECT_URI:-}
# Transactional email (account verification, password reset)
ZEPTO_API_TOKEN: ${ZEPTO_API_TOKEN:-}
ZEPTO_FROM_EMAIL: ${ZEPTO_FROM_EMAIL:-}
# Contact / abuse-report forms → Discord webhooks
CONTACT_WEBHOOK: ${CONTACT_WEBHOOK:-}
URL_REPORT_WEBHOOK: ${URL_REPORT_WEBHOOK:-}
# Bot protection on public forms
HCAPTCHA_SITEKEY: ${HCAPTCHA_SITEKEY:-}
HCAPTCHA_SECRET: ${HCAPTCHA_SECRET:-}
# Error tracking
SENTRY_DSN: ${SENTRY_DSN:-}
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s

mongo:
image: mongo:8
restart: unless-stopped
# Not exposed on the host — reachable only inside the compose network.
# Point MONGODB_URI at your own server (e.g. Atlas) to skip this
# container entirely; compose still starts it, but nothing talks to it.
volumes:
- mongo-data:/data/db
- mongo-config:/data/configdb
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s

redis:
image: redis:7-alpine
restart: unless-stopped
# Cache semantics: bounded memory, LRU eviction, no persistence — the
# cache rebuilds from Mongo. The app runs fine without Redis at all
# (set REDIS_URI to empty), just slower under load.
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--save ""
--appendonly no
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5

volumes:
mongo-data:
mongo-config:
37 changes: 35 additions & 2 deletions tests/unit/middleware/test_tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
from services.tenant_resolver.protocol import TenantInfo


def _app(resolver=None, omit_resolver: bool = False) -> Starlette:
def _app(
resolver=None,
omit_resolver: bool = False,
custom_domains_enabled: bool = True,
) -> Starlette:
async def root(request):
tenant = getattr(request.state, "tenant", "<unset>")
if tenant is None:
Expand All @@ -35,7 +39,9 @@ async def alias(request):
)
if not omit_resolver:
app.state.tenant_resolver = resolver
app.add_middleware(TenantMiddleware)
app.add_middleware(
TenantMiddleware, custom_domains_enabled=custom_domains_enabled
)
return app


Expand Down Expand Up @@ -96,6 +102,33 @@ def test_unknown_host_returns_html_404(self):
assert "This URL doesn" in r.text and "t exist" in r.text
assert "Not found" in r.text

def test_unknown_host_passes_through_when_custom_domains_disabled(self):
"""Self-host mode: hosts the app was never told about (IPs, internal
DNS, reverse-proxy names) serve the full app surface — the sitewide
404 is a SaaS-mode concern only."""
from starlette.testclient import TestClient

resolver = MagicMock()
resolver.resolve = AsyncMock(return_value=None)
app = _app(resolver, custom_domains_enabled=False)
with TestClient(app) as client:
r = client.get("/", headers={"host": "my-vps.example.com"})
assert r.status_code == 200
assert r.text == "none" # state.tenant is set to None, not left unset

def test_known_custom_tenant_still_resolves_when_disabled(self):
"""Flipping the feature off must not break live redirects on
already-registered domains."""
from starlette.testclient import TestClient

resolver = MagicMock()
resolver.resolve = AsyncMock(return_value=_custom_tenant())
app = _app(resolver, custom_domains_enabled=False)
with TestClient(app) as client:
r = client.get("/abc123", headers={"host": "links.acme.com"})
assert r.status_code == 200
assert r.text == "links.acme.com"

def test_system_tenant_root_passes_through(self):
resolver = MagicMock()
resolver.resolve = AsyncMock(return_value=_system_tenant())
Expand Down
Loading
Loading