Skip to content
Open
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
13 changes: 13 additions & 0 deletions delivery-kid/ansible/playbook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,13 @@
- IPFS_API_URL=http://ipfs:5001
- IPFS_GATEWAY_URL=https://{{ ipfs_gateway_domain }}
- STAGING_DIR=/staging
- COCONUT_API_KEY={{ coconut_api_key | default('') }}
volumes:
- /mnt/storage-box/staging:/staging
ports:
- "127.0.0.1:3001:3001"
- "6881:6881" # BitTorrent TCP
- "6881:6881/udp" # BitTorrent UDP
networks:
- delivery-kid-net
restart: unless-stopped
Expand Down Expand Up @@ -329,6 +332,14 @@
dest: /etc/caddy/Caddyfile
content: |
{{ domain_name }} {
route /webseed/* {
# BEP 19 webseed rewrite: BT clients request
# /webseed/{cid}/{torrent_name}/{file_path}
# Strip the torrent name and proxy to IPFS gateway as
# /ipfs/{cid}/{file_path}
uri path_regexp ^/webseed/([^/]+)/[^/]+/(.+)$ /ipfs/$1/$2
reverse_proxy localhost:8080
}
route {
reverse_proxy /health localhost:3001
reverse_proxy /version localhost:3001
Expand All @@ -344,6 +355,8 @@
reverse_proxy /draft-content* localhost:3001
reverse_proxy /unpin/* localhost:3001
reverse_proxy /enrich* localhost:3001
reverse_proxy /torrent* localhost:3001
reverse_proxy /staging/* localhost:3001
respond "delivery-kid pinning service" 200
}
}
Expand Down
1 change: 1 addition & 0 deletions delivery-kid/pinning-service/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ RUN useradd -m -u 1000 appuser
USER appuser

EXPOSE 3001
EXPOSE 6881

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3001"]
109 changes: 87 additions & 22 deletions delivery-kid/pinning-service/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,35 @@
logger = logging.getLogger(__name__)


def create_upload_token(api_key: str, username: str, timestamp: int) -> str:
"""Create an HMAC upload token for wiki-authenticated users."""
message = f"upload:{username}:{timestamp}"
def create_upload_token(api_key: str, username: str, timestamp: int, action: str = "upload") -> str:
"""Create an HMAC token for wiki-authenticated users.

Args:
api_key: Shared secret between wiki and delivery-kid.
username: Wiki username.
timestamp: Millisecond timestamp.
action: Token action prefix — "upload" for staging, "finalize" for pinning.
"""
message = f"{action}:{username}:{timestamp}"
return hmac.new(api_key.encode(), message.encode(), hashlib.sha256).hexdigest()


def verify_upload_token(token: str, username: str, timestamp: int, settings: Settings) -> bool:
"""Verify an HMAC upload token."""
def verify_upload_token(token: str, username: str, timestamp: int, settings: Settings, action: str = "upload") -> bool:
"""Verify an HMAC token.

Args:
token: The HMAC token to verify.
username: Wiki username claimed.
timestamp: Millisecond timestamp claimed.
settings: App settings.
action: Expected action prefix — "upload" or "finalize".
"""
if not settings.api_key:
logger.warning("HMAC verify failed: no api_key configured")
return False
expected = create_upload_token(settings.api_key, username, timestamp)
expected = create_upload_token(settings.api_key, username, timestamp, action=action)
if not hmac.compare_digest(token, expected):
logger.warning("HMAC verify failed: token mismatch for user=%s", username)
logger.warning("HMAC verify failed: token mismatch for user=%s action=%s", username, action)
return False
# Check timestamp freshness
now_ms = int(time.time() * 1000)
Expand All @@ -38,8 +53,8 @@ def verify_upload_token(token: str, username: str, timestamp: int, settings: Set
if drift_ms > max_drift_ms:
logger.warning(
"HMAC verify failed: token expired. drift=%dms (max=%dms), "
"token_ts=%d, server_now=%d, user=%s",
drift_ms, max_drift_ms, timestamp, now_ms, username
"token_ts=%d, server_now=%d, user=%s, action=%s",
drift_ms, max_drift_ms, timestamp, now_ms, username, action
)
return False
return True
Expand Down Expand Up @@ -103,6 +118,34 @@ def verify_signature(signature: str, timestamp: int, settings: Settings) -> Auth
return AuthResult(valid=True, address=address)


def _verify_hmac_headers(request: Request, settings: Settings, action: str = "upload") -> str | None:
"""Verify HMAC upload/finalize token from request headers.

Returns identity string on success, None if headers not present.
Raises HTTPException on invalid token.
"""
upload_token = request.headers.get("X-Upload-Token")
if not upload_token:
return None

username = request.headers.get("X-Upload-User", "")
timestamp_str = request.headers.get("X-Upload-Timestamp", "")
try:
timestamp = int(timestamp_str)
except (ValueError, TypeError):
raise HTTPException(
status_code=401,
detail={"error": "Invalid upload timestamp"}
)

if not verify_upload_token(upload_token, username, timestamp, settings, action=action):
raise HTTPException(
status_code=401,
detail={"error": "Invalid or expired upload token"}
)
return f"wiki:{username}"


async def require_wallet_auth(
request: Request,
settings: Settings = Depends(get_settings)
Expand Down Expand Up @@ -153,25 +196,47 @@ async def require_auth(
Returns an identity string.
"""
# 1. HMAC upload token (wiki-issued, for direct browser uploads)
upload_token = request.headers.get("X-Upload-Token")
if upload_token:
username = request.headers.get("X-Upload-User", "")
timestamp_str = request.headers.get("X-Upload-Timestamp", "")
try:
timestamp = int(timestamp_str)
except (ValueError, TypeError):
identity = _verify_hmac_headers(request, settings, action="upload")
if identity:
return identity

# 2. API key (server-to-server)
api_key = request.headers.get("X-API-Key")
if api_key:
if not settings.api_key:
raise HTTPException(
status_code=401,
detail={"error": "Invalid upload timestamp"}
status_code=500,
detail={"error": "API key auth not configured on server"}
)
if not verify_upload_token(upload_token, username, timestamp, settings):
if api_key != settings.api_key:
raise HTTPException(
status_code=401,
detail={"error": "Invalid or expired upload token"}
detail={"error": "Invalid API key"}
)
return f"wiki:{username}"
return request.headers.get("X-Uploaded-By", "api-user")

# 2. API key (server-to-server)
# 3. Wallet signature
return await require_wallet_auth(request, settings)


async def require_finalize_auth(
request: Request,
settings: Settings = Depends(get_settings)
) -> str:
"""
FastAPI dependency for finalization endpoints.

Requires a finalize-prefixed HMAC token (issued only to users with
finalize-release permission), an API key, or a wallet signature.

Returns an identity string.
"""
# 1. HMAC finalize token (wiki-issued, for finalize-release users)
identity = _verify_hmac_headers(request, settings, action="finalize")
if identity:
return identity

# 2. API key (server-to-server — always allowed to finalize)
api_key = request.headers.get("X-API-Key")
if api_key:
if not settings.api_key:
Expand Down
14 changes: 13 additions & 1 deletion delivery-kid/pinning-service/app/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Configuration settings loaded from environment variables."""

import os
from pydantic_settings import BaseSettings
from functools import lru_cache

Expand All @@ -19,9 +20,15 @@ class Settings(BaseSettings):
# Staging directory for uploads and transcoding
staging_dir: str = "/staging"

# Seeding directory for BitTorrent (persistent, on storage box)
seeding_dir: str = "/staging/seeding"

# Authorized wallets (comma-separated)
authorized_wallets: str = ""

# Coconut.co cloud transcoding
coconut_api_key: str = ""

# Auth settings
max_timestamp_drift_seconds: int = 3600 # 1 hour — token generated at page load, user may browse before uploading
api_key: str = "" # Shared API key for server-to-server auth (e.g., from PickiPedia)
Expand All @@ -40,7 +47,7 @@ class Settings(BaseSettings):
"https://www.cryptograss.live",
"https://pickipedia.xyz",
]
cors_origin_regex: str = r"https://\w+\d*\.hunter\.cryptograss\.live"
cors_origin_regex: str = r"https://[\w.-]+\.hunter\.cryptograss\.live"

class Config:
env_file = ".env"
Expand All @@ -56,3 +63,8 @@ def authorized_wallet_list(self) -> list[str]:
@lru_cache
def get_settings() -> Settings:
return Settings()


def get_commit() -> str:
"""Return the git commit hash baked into this build."""
return os.environ.get("GIT_COMMIT", "unknown")
14 changes: 12 additions & 2 deletions delivery-kid/pinning-service/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
from fastapi.middleware.cors import CORSMiddleware

from .config import get_settings
from .routes import health, albums, drafts, content, enrich
from .routes import health, albums, drafts, content, enrich, torrent, coconut, staging
from .services import cleanup
from .services.seeder import init_seeder, stop_seeder

# Configure logging
logging.basicConfig(
Expand Down Expand Up @@ -47,10 +48,16 @@ async def lifespan(app: FastAPI):
cleanup.periodic_cleanup(staging_dir, interval_seconds=3600)
)

# Start BitTorrent seeder
init_seeder(settings.seeding_dir)

logger.info("Delivery Kid pinning service started")
yield

# Shutdown: cancel cleanup task
# Shutdown: stop seeder first
stop_seeder()

# Cancel cleanup task
cleanup_task.cancel()
try:
await cleanup_task
Expand Down Expand Up @@ -84,6 +91,9 @@ async def lifespan(app: FastAPI):
app.include_router(drafts.router)
app.include_router(content.router)
app.include_router(enrich.router)
app.include_router(torrent.router)
app.include_router(coconut.router)
app.include_router(staging.router)


@app.get("/")
Expand Down
12 changes: 11 additions & 1 deletion delivery-kid/pinning-service/app/models/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class ContentDraftResponse(BaseModel):
expires_at: datetime
files: list[ContentFile]
metadata: dict = Field(default_factory=dict)
commit: str = Field(default="unknown", description="Git commit hash of the build that created this draft")


class ContentFinalizeRequest(BaseModel):
Expand All @@ -51,5 +52,14 @@ class ContentFinalizeRequest(BaseModel):
description: Optional[str] = None
file_type: Optional[str] = Field(default=None, description="MIME type override (e.g., video/webm)")
metadata: dict = Field(default_factory=dict, description="Arbitrary metadata for Release page")
transcode_hls: bool = Field(default=False, description="Transcode video to HLS before pinning")
transcode_hls: bool = Field(default=False, description="Transcode video to HLS before pinning (legacy, use transcoding_strategy)")
transcoding_strategy: str = Field(
default="auto",
description="Transcoding strategy for video: 'auto' (Coconut first, local fallback), 'coconut', 'local', 'none'"
)
subsequent_to: Optional[str] = Field(default=None, description="CID this content supersedes")
transcoding_qualities: Optional[list[int]] = Field(
default=None,
description="Output video heights for HLS transcoding, e.g. [1080, 720, 480]. "
"Default [720, 480]. Common values: 2160 (4K), 1080, 720, 480, 360."
)
1 change: 1 addition & 0 deletions delivery-kid/pinning-service/app/models/draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class DraftResponse(BaseModel):
draft_id: str
expires_at: datetime
files: list[DraftFile]
commit: str = Field(default="unknown", description="Git commit hash of the build that created this draft")


class FinalizeTrack(BaseModel):
Expand Down
Loading