Update backend configuration and dependencies
This commit is contained in:
parent
4d29a097a3
commit
77d1277cd8
9 changed files with 300 additions and 81 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
90
backend/app/api/v1/sso.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue