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 \
&& 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

View file

@ -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"]

View file

@ -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)
@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 = []
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.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"}
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.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
}

View file

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

View file

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

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_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"
case_sensitive = False

View file

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

View file

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