diff --git a/backend/Dockerfile b/backend/Dockerfile index ffcb568..1f69840 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -26,19 +26,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Copy Python packages from builder -COPY --from=builder /root/.local /root/.local +# Create non-root user for security +RUN useradd -m -u 1000 appuser + +# Copy Python packages from builder to appuser's home +COPY --from=builder /root/.local /home/appuser/.local + +# Fix permissions +RUN chown -R appuser:appuser /app /home/appuser/.local # Set environment variables -ENV PATH=/root/.local/bin:$PATH +ENV PATH=/home/appuser/.local/bin:$PATH ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 # Copy application code COPY . . +RUN chown -R appuser:appuser /app -# Create non-root user for security -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser # Health check diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 12d6f84..de064c0 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -5,7 +5,7 @@ Job ID: MTAD-IMPL-2025-11-18-CL """ from fastapi import APIRouter -from app.api.v1 import auth, blog, forum, merch, podcast, profiles, resources, tribute, health +from app.api.v1 import auth, blog, forum, merch, podcast, profiles, resources, tribute, health, sso api_router = APIRouter() @@ -21,5 +21,6 @@ api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"] api_router.include_router(resources.router, prefix="/resources", tags=["Resources"]) api_router.include_router(tribute.router, prefix="/tribute", tags=["Tribute"]) api_router.include_router(health.router, tags=["Health"]) +api_router.include_router(sso.router, prefix="/sso", tags=["SSO"]) __all__ = ["api_router"] diff --git a/backend/app/api/v1/blog.py b/backend/app/api/v1/blog.py index fad9480..b3bfddb 100644 --- a/backend/app/api/v1/blog.py +++ b/backend/app/api/v1/blog.py @@ -1,49 +1,69 @@ """ -Blog MVP API endpoints. - +Blog API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL """ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session -from app.database import get_db -from app.models import BlogPost +from fastapi import APIRouter, HTTPException, status +import feedparser +import httpx +from datetime import datetime +from time import mktime router = APIRouter() +# TODO: Get this from settings/env +RSS_FEED_URL = "https://morethanadiagnosis.org/feed/" # Placeholder, need actual URL -@router.get("/") -async def list_blog_posts(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): - """List all published blog posts.""" - posts = db.query(BlogPost).filter(BlogPost.published_at.isnot(None)).offset(skip).limit(limit).all() - return {"items": posts, "count": len(posts)} +@router.get("/rss") +async def get_blog_rss(): + """Fetch and parse blog posts from WordPress RSS feed.""" + async with httpx.AsyncClient() as client: + try: + response = await client.get(RSS_FEED_URL) + response.raise_for_status() + except httpx.RequestError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Error fetching blog feed: {exc}" + ) + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Error fetching blog feed: {exc}" + ) + feed = feedparser.parse(response.text) + + posts = [] + for entry in feed.entries: + # Parse published date + published_at = None + if hasattr(entry, 'published_parsed'): + published_at = datetime.fromtimestamp(mktime(entry.published_parsed)) + + # Extract image if available (WordPress often puts it in content or media:content) + image_url = None + if 'media_content' in entry: + image_url = entry.media_content[0]['url'] + elif 'links' in entry: + for link in entry.links: + if link.get('rel') == 'enclosure' and link.get('type', '').startswith('image/'): + image_url = link['href'] + break -@router.get("/{post_id}") -async def get_blog_post(post_id: str, db: Session = Depends(get_db)): - """Get a specific blog post by ID.""" - post = db.query(BlogPost).filter(BlogPost.id == post_id).first() - if not post: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blog post not found") - return post + posts.append({ + "id": entry.get("id", entry.get("link")), + "title": entry.get("title"), + "link": entry.get("link"), + "published_at": published_at, + "summary": entry.get("summary"), + "content": entry.get("content", [{"value": ""}])[0]["value"], + "image_url": image_url, + "author": entry.get("author") + }) - -@router.post("/") -async def create_blog_post(title: str, slug: str, content: str, db: Session = Depends(get_db)): - """Create a new blog post (requires authentication).""" - # TODO: Implement with proper auth and validation - return {"message": "Blog post creation not yet implemented"} - - -@router.put("/{post_id}") -async def update_blog_post(post_id: str, title: str, content: str, db: Session = Depends(get_db)): - """Update a blog post (requires authentication and ownership).""" - # TODO: Implement with proper auth and validation - return {"message": "Blog post update not yet implemented"} - - -@router.delete("/{post_id}") -async def delete_blog_post(post_id: str, db: Session = Depends(get_db)): - """Delete a blog post (requires authentication and ownership).""" - # TODO: Implement with proper auth and validation - return {"message": "Blog post deletion not yet implemented"} + return { + "title": feed.feed.get("title", "Blog"), + "description": feed.feed.get("description", ""), + "items": posts + } diff --git a/backend/app/api/v1/forum.py b/backend/app/api/v1/forum.py index 81ed3dd..333799c 100644 --- a/backend/app/api/v1/forum.py +++ b/backend/app/api/v1/forum.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.database import get_db from app.models import ForumCategory, ForumThread, ForumPost +import uuid router = APIRouter() @@ -27,14 +28,52 @@ async def list_posts(thread_id: str, skip: int = 0, limit: int = 20, db: Session @router.post("/categories") async def create_category(name: str, db: Session = Depends(get_db)): """Create forum category (admin only).""" - return {"message": "Category creation not yet implemented"} + # Mock implementation for MVP + category = ForumCategory( + id=str(uuid.uuid4()), + name=name, + slug=name.lower().replace(" ", "-"), + description=f"Discussions about {name}", + order=0 + ) + db.add(category) + db.commit() + return category @router.post("/threads") -async def create_thread(category_id: str, title: str, db: Session = Depends(get_db)): - """Create forum thread (requires authentication).""" - return {"message": "Thread creation not yet implemented"} +async def create_thread(category_id: str, title: str, content: str, user_id: str = "test-user", db: Session = Depends(get_db)): + """Create forum thread.""" + # Mock user for MVP + thread = ForumThread( + id=str(uuid.uuid4()), + category_id=category_id, + author_id=user_id, + title=title, + slug=title.lower().replace(" ", "-"), + ) + db.add(thread) + + # Create initial post + post = ForumPost( + id=str(uuid.uuid4()), + thread_id=thread.id, + author_id=user_id, + content=content + ) + db.add(post) + + db.commit() + return thread @router.post("/posts") -async def create_post(thread_id: str, content: str, db: Session = Depends(get_db)): - """Create forum post (requires authentication).""" - return {"message": "Post creation not yet implemented"} +async def create_post(thread_id: str, content: str, user_id: str = "test-user", db: Session = Depends(get_db)): + """Create forum post.""" + post = ForumPost( + id=str(uuid.uuid4()), + thread_id=thread_id, + author_id=user_id, + content=content + ) + db.add(post) + db.commit() + return post diff --git a/backend/app/api/v1/podcast.py b/backend/app/api/v1/podcast.py index 2ef2cf4..b12f3c2 100644 --- a/backend/app/api/v1/podcast.py +++ b/backend/app/api/v1/podcast.py @@ -1,27 +1,84 @@ -"""Podcast MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL""" -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session -from app.database import get_db -from app.models import PodcastEpisode +from fastapi import APIRouter, HTTPException +import feedparser +import httpx +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime router = APIRouter() -@router.get("/episodes") -async def list_episodes(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)): - """List all podcast episodes.""" - episodes = db.query(PodcastEpisode).filter(PodcastEpisode.published_at.isnot(None)).order_by(PodcastEpisode.published_at.desc()).offset(skip).limit(limit).all() - return {"items": episodes} +RSS_FEED_URL = "https://rss.libsyn.com/shows/563180/destinations/4867845.xml" -@router.get("/episodes/{episode_id}") -async def get_episode(episode_id: str, db: Session = Depends(get_db)): - """Get a specific episode.""" - episode = db.query(PodcastEpisode).filter(PodcastEpisode.id == episode_id).first() - if not episode: - from fastapi import HTTPException, status - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Episode not found") - return episode +class PodcastEpisode(BaseModel): + id: str + title: str + description: str + audio_url: str + published_at: str + duration: Optional[str] = None + image_url: Optional[str] = None -@router.post("/episodes") -async def create_episode(title: str, audio_url: str, db: Session = Depends(get_db)): - """Create podcast episode (admin only).""" - return {"message": "Episode creation not yet implemented"} +class PodcastFeed(BaseModel): + title: str + description: str + image_url: Optional[str] = None + episodes: List[PodcastEpisode] + +@router.get("/", response_model=PodcastFeed) +async def get_podcasts(): + try: + async with httpx.AsyncClient() as client: + response = await client.get(RSS_FEED_URL) + response.raise_for_status() + rss_content = response.text + + feed = feedparser.parse(rss_content) + + if feed.bozo: + # Log warning but try to proceed if possible, or raise error + print(f"Warning: Feed parsing error: {feed.bozo_exception}") + + episodes = [] + for entry in feed.entries: + # Extract audio URL + audio_url = "" + for link in entry.links: + if link.type == "audio/mpeg": + audio_url = link.href + break + + if not audio_url: + continue # Skip episodes without audio + + # Extract image + image_url = None + if 'image' in entry: + image_url = entry.image.href + elif 'itunes_image' in entry: + image_url = entry.itunes_image.href + + episodes.append(PodcastEpisode( + id=entry.id, + title=entry.title, + description=entry.summary, + audio_url=audio_url, + published_at=entry.published, + duration=entry.get('itunes_duration'), + image_url=image_url + )) + + feed_image = None + if 'image' in feed.feed: + feed_image = feed.feed.image.href + elif 'itunes_image' in feed.feed: + feed_image = feed.feed.itunes_image.href + + return PodcastFeed( + title=feed.feed.title, + description=feed.feed.description, + image_url=feed_image, + episodes=episodes + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch podcasts: {str(e)}") diff --git a/backend/app/api/v1/sso.py b/backend/app/api/v1/sso.py new file mode 100644 index 0000000..3a91f82 --- /dev/null +++ b/backend/app/api/v1/sso.py @@ -0,0 +1,90 @@ +""" +Discourse SSO (Connect) integration. +""" + +import base64 +import hmac +import hashlib +import urllib.parse +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import User +from app.api.v1.auth import get_current_user +from app.config import settings + +router = APIRouter() + + +@router.get("/discourse") +async def discourse_sso( + sso: str, + sig: str, + current_user: User = Depends(get_current_user), +): + """ + Handle Discourse SSO login request. + + 1. Validate the signature of the incoming payload. + 2. Decode the payload (nonce). + 3. Construct a new payload with user data. + 4. Sign the new payload. + 5. Redirect back to Discourse. + """ + if not settings.discourse_sso_secret: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Discourse SSO secret not configured" + ) + + # 1. Validate Signature + secret = settings.discourse_sso_secret.encode("utf-8") + expected_sig = hmac.new(secret, sso.encode("utf-8"), hashlib.sha256).hexdigest() + + if sig != expected_sig: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid SSO signature" + ) + + # 2. Decode Payload + try: + decoded_sso = base64.b64decode(sso).decode("utf-8") + params = urllib.parse.parse_qs(decoded_sso) + nonce = params.get("nonce", [None])[0] + + if not nonce: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing nonce in SSO payload" + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid SSO payload" + ) + + # 3. Construct User Payload + # Discourse expects: nonce, email, external_id, username, name, etc. + user_params = { + "nonce": nonce, + "email": current_user.email, + "external_id": current_user.id, + "username": current_user.profile.display_name.replace(" ", "_") if current_user.profile and current_user.profile.display_name else f"user_{current_user.id[:8]}", + "name": current_user.profile.display_name if current_user.profile else "", + "require_activation": "false", + } + + # 4. Encode and Sign + encoded_params = urllib.parse.urlencode(user_params) + base64_params = base64.b64encode(encoded_params.encode("utf-8")).decode("utf-8") + new_sig = hmac.new(secret, base64_params.encode("utf-8"), hashlib.sha256).hexdigest() + + # 5. Redirect + # Redirect to Discourse's return URL + # Usually: {DISCOURSE_URL}/session/sso_login + return_url = f"{settings.discourse_url}/session/sso_login" + redirect_url = f"{return_url}?sso={base64_params}&sig={new_sig}" + + return RedirectResponse(url=redirect_url) diff --git a/backend/app/config.py b/backend/app/config.py index 5e40463..c8c8b16 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -77,6 +77,10 @@ class Settings(BaseSettings): feature_mfa_enabled: bool = True feature_social_login_enabled: bool = False feature_pseudonym_enabled: bool = True + + # Discourse + discourse_sso_secret: str = "change-me-in-production" + discourse_url: str = "https://forum.morethanadiagnosis.org" class Config: env_file = ".env" diff --git a/backend/docker-compose.gemini.yml b/backend/docker-compose.gemini.yml index 7583ff1..779c612 100644 --- a/backend/docker-compose.gemini.yml +++ b/backend/docker-compose.gemini.yml @@ -1,4 +1,4 @@ - version: '3.8' +version: '3.8' # Gemini Parallel Deployment # Ports shifted to avoid conflict with production @@ -12,7 +12,6 @@ services: POSTGRES_USER: admin POSTGRES_PASSWORD: ${DB_PASSWORD:-gemini-test-password} POSTGRES_DB: morethanadiagnosis - POSTGRES_INITDB_ARGS: "-c shared_preload_libraries=pg_stat_statements" volumes: - postgres_data_gemini:/var/lib/postgresql/data ports: @@ -20,7 +19,7 @@ services: networks: - mtad-network-gemini healthcheck: - test: ["CMD-SHELL", "pg_isready -U admin -d morethanadiagnosis"] + test: [ "CMD-SHELL", "pg_isready -U admin -d morethanadiagnosis" ] interval: 10s timeout: 5s retries: 5 @@ -37,7 +36,7 @@ services: - mtad-network-gemini command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gemini-test-password} healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 5 @@ -54,7 +53,7 @@ services: DATABASE_URL: postgresql://admin:${DB_PASSWORD:-gemini-test-password}@postgres:5432/morethanadiagnosis REDIS_URL: redis://:${REDIS_PASSWORD:-gemini-test-password}@redis:6379/0 SECRET_KEY: ${SECRET_KEY:-gemini-test-secret} - CORS_ORIGINS: '["http://localhost:3001", "http://localhost:8081"]' + CORS_ORIGINS: '["http://localhost:3001", "http://localhost:8081", "http://216.158.230.94:8081"]' LOG_LEVEL: DEBUG ports: - "8001:8000" @@ -68,7 +67,7 @@ services: volumes: - ./app:/app/app:ro healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] interval: 30s timeout: 10s retries: 3 @@ -78,6 +77,8 @@ services: build: context: ../web dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_BASE_URL: http://216.158.230.94:8001/api/v1 container_name: mtad-web-gemini restart: unless-stopped expose: @@ -87,7 +88,7 @@ services: depends_on: - api environment: - NEXT_PUBLIC_API_BASE_URL: http://api:8000/api/v1 + NEXT_PUBLIC_API_BASE_URL: http://216.158.230.94:8001/api/v1 nginx: image: nginx:alpine @@ -105,7 +106,7 @@ services: depends_on: - api healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] + test: [ "CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health" ] interval: 30s timeout: 10s retries: 3 diff --git a/backend/requirements.txt b/backend/requirements.txt index 4143952..b721fbb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -36,8 +36,10 @@ opentelemetry-exporter-jaeger==1.21.0 opentelemetry-instrumentation-fastapi==0.42b0 opentelemetry-instrumentation-sqlalchemy==0.42b0 +# Data Validation & Transformation # Data Validation & Transformation marshmallow==3.20.1 +feedparser>=6.0.10 # Testing pytest==7.4.3