Update backend configuration and dependencies

This commit is contained in:
fullsizemalt 2025-11-19 09:42:15 -08:00
parent 4d29a097a3
commit 77d1277cd8
9 changed files with 300 additions and 81 deletions

View file

@ -26,19 +26,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy Python packages from builder # Create non-root user for security
COPY --from=builder /root/.local /root/.local 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 # Set environment variables
ENV PATH=/root/.local/bin:$PATH ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
# Copy application code # Copy application code
COPY . . 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 USER appuser
# Health check # Health check

View file

@ -5,7 +5,7 @@ Job ID: MTAD-IMPL-2025-11-18-CL
""" """
from fastapi import APIRouter 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() 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(resources.router, prefix="/resources", tags=["Resources"])
api_router.include_router(tribute.router, prefix="/tribute", tags=["Tribute"]) api_router.include_router(tribute.router, prefix="/tribute", tags=["Tribute"])
api_router.include_router(health.router, tags=["Health"]) api_router.include_router(health.router, tags=["Health"])
api_router.include_router(sso.router, prefix="/sso", tags=["SSO"])
__all__ = ["api_router"] __all__ = ["api_router"]

View file

@ -1,49 +1,69 @@
""" """
Blog MVP API endpoints. Blog API endpoints.
Job ID: MTAD-IMPL-2025-11-18-CL Job ID: MTAD-IMPL-2025-11-18-CL
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, HTTPException, status
from sqlalchemy.orm import Session import feedparser
from app.database import get_db import httpx
from app.models import BlogPost from datetime import datetime
from time import mktime
router = APIRouter() router = APIRouter()
# TODO: Get this from settings/env
RSS_FEED_URL = "https://morethanadiagnosis.org/feed/" # Placeholder, need actual URL
@router.get("/") @router.get("/rss")
async def list_blog_posts(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): async def get_blog_rss():
"""List all published blog posts.""" """Fetch and parse blog posts from WordPress RSS feed."""
posts = db.query(BlogPost).filter(BlogPost.published_at.isnot(None)).offset(skip).limit(limit).all() async with httpx.AsyncClient() as client:
return {"items": posts, "count": len(posts)} 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)
@router.get("/{post_id}") posts = []
async def get_blog_post(post_id: str, db: Session = Depends(get_db)): for entry in feed.entries:
"""Get a specific blog post by ID.""" # Parse published date
post = db.query(BlogPost).filter(BlogPost.id == post_id).first() published_at = None
if not post: if hasattr(entry, 'published_parsed'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blog post not found") published_at = datetime.fromtimestamp(mktime(entry.published_parsed))
return post
# 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.post("/") posts.append({
async def create_blog_post(title: str, slug: str, content: str, db: Session = Depends(get_db)): "id": entry.get("id", entry.get("link")),
"""Create a new blog post (requires authentication).""" "title": entry.get("title"),
# TODO: Implement with proper auth and validation "link": entry.get("link"),
return {"message": "Blog post creation not yet implemented"} "published_at": published_at,
"summary": entry.get("summary"),
"content": entry.get("content", [{"value": ""}])[0]["value"],
"image_url": image_url,
"author": entry.get("author")
})
return {
@router.put("/{post_id}") "title": feed.feed.get("title", "Blog"),
async def update_blog_post(post_id: str, title: str, content: str, db: Session = Depends(get_db)): "description": feed.feed.get("description", ""),
"""Update a blog post (requires authentication and ownership).""" "items": posts
# 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"}

View file

@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import ForumCategory, ForumThread, ForumPost from app.models import ForumCategory, ForumThread, ForumPost
import uuid
router = APIRouter() router = APIRouter()
@ -27,14 +28,52 @@ async def list_posts(thread_id: str, skip: int = 0, limit: int = 20, db: Session
@router.post("/categories") @router.post("/categories")
async def create_category(name: str, db: Session = Depends(get_db)): async def create_category(name: str, db: Session = Depends(get_db)):
"""Create forum category (admin only).""" """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") @router.post("/threads")
async def create_thread(category_id: str, title: str, db: Session = Depends(get_db)): async def create_thread(category_id: str, title: str, content: str, user_id: str = "test-user", db: Session = Depends(get_db)):
"""Create forum thread (requires authentication).""" """Create forum thread."""
return {"message": "Thread creation not yet implemented"} # 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") @router.post("/posts")
async def create_post(thread_id: str, content: str, db: Session = Depends(get_db)): async def create_post(thread_id: str, content: str, user_id: str = "test-user", db: Session = Depends(get_db)):
"""Create forum post (requires authentication).""" """Create forum post."""
return {"message": "Post creation not yet implemented"} post = ForumPost(
id=str(uuid.uuid4()),
thread_id=thread_id,
author_id=user_id,
content=content
)
db.add(post)
db.commit()
return post

View file

@ -1,27 +1,84 @@
"""Podcast MVP API endpoints. Job ID: MTAD-IMPL-2025-11-18-CL""" from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends import feedparser
from sqlalchemy.orm import Session import httpx
from app.database import get_db from pydantic import BaseModel
from app.models import PodcastEpisode from typing import List, Optional
from datetime import datetime
router = APIRouter() router = APIRouter()
@router.get("/episodes") RSS_FEED_URL = "https://rss.libsyn.com/shows/563180/destinations/4867845.xml"
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}
@router.get("/episodes/{episode_id}") class PodcastEpisode(BaseModel):
async def get_episode(episode_id: str, db: Session = Depends(get_db)): id: str
"""Get a specific episode.""" title: str
episode = db.query(PodcastEpisode).filter(PodcastEpisode.id == episode_id).first() description: str
if not episode: audio_url: str
from fastapi import HTTPException, status published_at: str
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Episode not found") duration: Optional[str] = None
return episode image_url: Optional[str] = None
@router.post("/episodes") class PodcastFeed(BaseModel):
async def create_episode(title: str, audio_url: str, db: Session = Depends(get_db)): title: str
"""Create podcast episode (admin only).""" description: str
return {"message": "Episode creation not yet implemented"} 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)}")

90
backend/app/api/v1/sso.py Normal file
View file

@ -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)

View file

@ -78,6 +78,10 @@ class Settings(BaseSettings):
feature_social_login_enabled: bool = False feature_social_login_enabled: bool = False
feature_pseudonym_enabled: bool = True feature_pseudonym_enabled: bool = True
# Discourse
discourse_sso_secret: str = "change-me-in-production"
discourse_url: str = "https://forum.morethanadiagnosis.org"
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = False case_sensitive = False

View file

@ -12,7 +12,6 @@ services:
POSTGRES_USER: admin POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASSWORD:-gemini-test-password} POSTGRES_PASSWORD: ${DB_PASSWORD:-gemini-test-password}
POSTGRES_DB: morethanadiagnosis POSTGRES_DB: morethanadiagnosis
POSTGRES_INITDB_ARGS: "-c shared_preload_libraries=pg_stat_statements"
volumes: volumes:
- postgres_data_gemini:/var/lib/postgresql/data - postgres_data_gemini:/var/lib/postgresql/data
ports: ports:
@ -54,7 +53,7 @@ services:
DATABASE_URL: postgresql://admin:${DB_PASSWORD:-gemini-test-password}@postgres:5432/morethanadiagnosis DATABASE_URL: postgresql://admin:${DB_PASSWORD:-gemini-test-password}@postgres:5432/morethanadiagnosis
REDIS_URL: redis://:${REDIS_PASSWORD:-gemini-test-password}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:-gemini-test-password}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-gemini-test-secret} 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 LOG_LEVEL: DEBUG
ports: ports:
- "8001:8000" - "8001:8000"
@ -78,6 +77,8 @@ services:
build: build:
context: ../web context: ../web
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_BASE_URL: http://216.158.230.94:8001/api/v1
container_name: mtad-web-gemini container_name: mtad-web-gemini
restart: unless-stopped restart: unless-stopped
expose: expose:
@ -87,7 +88,7 @@ services:
depends_on: depends_on:
- api - api
environment: 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: nginx:
image: nginx:alpine image: nginx:alpine

View file

@ -36,8 +36,10 @@ opentelemetry-exporter-jaeger==1.21.0
opentelemetry-instrumentation-fastapi==0.42b0 opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-sqlalchemy==0.42b0 opentelemetry-instrumentation-sqlalchemy==0.42b0
# Data Validation & Transformation
# Data Validation & Transformation # Data Validation & Transformation
marshmallow==3.20.1 marshmallow==3.20.1
feedparser>=6.0.10
# Testing # Testing
pytest==7.4.3 pytest==7.4.3