Skip to content
Merged
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
7 changes: 5 additions & 2 deletions ushadow/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from src.models.user import User # Beanie document model
from src.models.share import ShareToken # Beanie document model
from src.models.feed import Post # Beanie document model (PostSource uses SettingsStore)
from src.models.feed import PostSource, Post, MastodonAppCredential # Beanie document models

from src.routers import health, wizard, chronicle, auth, feature_flags
from src.routers import services, deployments, providers, service_configs, chat
Expand Down Expand Up @@ -134,7 +134,10 @@ def send_telemetry():
app.state.db = db

# Initialize Beanie ODM with document models
await init_beanie(database=db, document_models=[User, ShareToken, Post])
await init_beanie(
database=db,
document_models=[User, ShareToken, PostSource, Post, MastodonAppCredential],
)
logger.info("✓ Beanie ODM initialized")

# Create admin user if explicitly configured in secrets.yaml
Expand Down
3 changes: 3 additions & 0 deletions ushadow/backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ email-validator==2.2.0

# HTTP Client for Chronicle/MCP/Agent Zero
httpx==0.27.2

# Mastodon / ActivityPub client (OAuth2 + REST API)
Mastodon.py>=1.8.0
aiohttp==3.11.7

# LLM Integration - Unified API for multiple providers
Expand Down
21 changes: 21 additions & 0 deletions ushadow/backend/src/models/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class PostSource(BaseModel):
description="Transient — injected from secrets at fetch time, never persisted",
exclude=True,
)
# Mastodon OAuth2 — when set, fetches from the user's authenticated home timeline
# instead of public hashtag timelines.
access_token: Optional[str] = Field(
default=None, description="Mastodon OAuth2 access token"
)
enabled: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)

Expand All @@ -55,6 +60,22 @@ class PostSource(BaseModel):
# =============================================================================


class MastodonAppCredential(Document):
"""Cached OAuth2 app credentials per Mastodon instance.

Mastodon requires registering an application before starting OAuth.
We register once per instance URL and cache the client_id / client_secret.
"""

instance_url: str = Field(..., description="Normalised instance base URL")
client_id: str
client_secret: str

class Settings:
name = "mastodon_app_credentials"
indexes = ["instance_url"]


class Post(Document):
"""A content item from any platform, scored against the user's interests."""

Expand Down
58 changes: 58 additions & 0 deletions ushadow/backend/src/routers/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
from src.config.store import SettingsStore, get_settings_store
from src.database import get_database
from src.models.feed import SourceCreate
from pydantic import BaseModel


class MastodonConnectRequest(BaseModel):
instance_url: str
code: str
redirect_uri: str
name: str = "Mastodon"
from src.services.auth import get_current_user
from src.services.feed_service import FeedService
from src.utils.auth_helpers import get_user_email
Expand Down Expand Up @@ -48,6 +56,56 @@ async def list_sources(
return {"sources": sources}


@router.get("/sources/mastodon/auth-url")
async def mastodon_auth_url(
instance_url: str = Query(..., description="Mastodon instance URL, e.g. mastodon.social"),
redirect_uri: str = Query(..., description="App redirect URI for OAuth callback"),
service: FeedService = Depends(get_feed_service),
current_user=Depends(get_current_user),
):
"""Return a Mastodon OAuth2 authorization URL.

The client should open this URL in a browser. After the user authorises,
Mastodon redirects to redirect_uri?code=<code>. Pass that code to
POST /api/feed/sources/mastodon/connect.
"""
try:
url = await service.get_mastodon_auth_url(instance_url, redirect_uri)
return {"authorization_url": url}
except Exception as e:
logger.error(f"Mastodon auth URL error: {e}")
raise HTTPException(status_code=502, detail=str(e))


@router.post("/sources/mastodon/connect", status_code=201)
async def mastodon_connect(
data: MastodonConnectRequest,
service: FeedService = Depends(get_feed_service),
current_user=Depends(get_current_user),
):
"""Exchange a Mastodon OAuth2 code for an access token and save the source.

Creates a new PostSource (or updates an existing one for the same instance)
with the access token. Future refreshes will pull from the authenticated
home timeline instead of public hashtag timelines.
"""
user_id = get_user_email(current_user)
try:
source = await service.connect_mastodon(
user_id=user_id,
instance_url=data.instance_url,
code=data.code,
redirect_uri=data.redirect_uri,
name=data.name,
)
return source
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Mastodon connect error: {e}")
raise HTTPException(status_code=502, detail=str(e))


@router.post("/sources", status_code=201)
async def add_source(
data: SourceCreate,
Expand Down
48 changes: 48 additions & 0 deletions ushadow/backend/src/services/feed_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
SourceCreate,
)
from src.services.interest_extractor import InterestExtractor
from src.services.mastodon_oauth import MastodonOAuthService
from src.services.post_fetcher import PostFetcher
from src.services.post_scorer import PostScorer

Expand Down Expand Up @@ -83,6 +84,53 @@ async def list_sources(self, user_id: str) -> List[PostSource]:
if s.get("user_id") == user_id
]

async def get_mastodon_auth_url(
self, instance_url: str, redirect_uri: str
) -> str:
"""Register app (or reuse cached) and return Mastodon authorization URL."""
oauth = MastodonOAuthService()
return await oauth.get_authorization_url(instance_url, redirect_uri)

async def connect_mastodon(
self,
user_id: str,
instance_url: str,
code: str,
redirect_uri: str,
name: str,
) -> PostSource:
"""Exchange OAuth code for a token and create/update a Mastodon source.

If a source already exists for this user + instance, the token is
refreshed in-place. Otherwise a new PostSource is created.
"""
oauth = MastodonOAuthService()
access_token = await oauth.exchange_code(instance_url, code, redirect_uri)

normalised_url = instance_url.rstrip("/")
existing = await PostSource.find_one(
PostSource.user_id == user_id,
PostSource.platform_type == "mastodon",
PostSource.instance_url == normalised_url,
)
if existing:
existing.access_token = access_token
existing.name = name
await existing.save()
logger.info(f"Updated Mastodon token for {user_id} on {normalised_url}")
return existing

source = PostSource(
user_id=user_id,
name=name,
platform_type="mastodon",
instance_url=normalised_url,
access_token=access_token,
)
await source.insert()
logger.info(f"Connected Mastodon account for {user_id} on {normalised_url}")
return source

async def remove_source(self, user_id: str, source_id: str) -> bool:
"""Remove a post source from config."""
all_sources = await self._settings.get(_SOURCES_KEY, default=[]) or []
Expand Down
132 changes: 132 additions & 0 deletions ushadow/backend/src/services/mastodon_oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Mastodon OAuth2 service — app registration and token exchange.

Uses Mastodon.py for the OAuth dance (app registration, URL generation,
code exchange). The library's synchronous calls are run via asyncio.to_thread
so they don't block the event loop.

Flow:
1. GET /api/feed/sources/mastodon/auth-url?instance_url=...&redirect_uri=...
→ registers app (or reuses cached credentials)
→ returns authorization URL to open in-browser
2. User authorises → redirect to redirect_uri?code=xxx
3. POST /api/feed/sources/mastodon/connect
{ instance_url, code, redirect_uri, name }
→ exchanges code for access_token
→ saves PostSource with token
"""

import asyncio
import logging

from mastodon import Mastodon

from src.models.feed import MastodonAppCredential

logger = logging.getLogger(__name__)

_SCOPES = ["read"]
_APP_NAME = "Ushadow"


class MastodonOAuthService:
"""Handles OAuth2 registration and token exchange with Mastodon instances."""

async def get_authorization_url(
self, instance_url: str, redirect_uri: str
) -> str:
"""Return the Mastodon authorization URL for the given instance.

Registers an OAuth2 app on the instance if not already cached.
"""
instance_url = _normalise(instance_url)
cred = await self._get_or_register_app(instance_url, redirect_uri)

def _build() -> str:
m = Mastodon(
client_id=cred.client_id,
client_secret=cred.client_secret,
api_base_url=instance_url,
)
return m.auth_request_url(
redirect_uris=redirect_uri,
scopes=_SCOPES,
)

url: str = await asyncio.to_thread(_build)
logger.info(f"Generated Mastodon auth URL for {instance_url}")
return url

async def exchange_code(
self, instance_url: str, code: str, redirect_uri: str
) -> str:
"""Exchange an authorization code for an access token.

Returns:
The access token string.

Raises:
ValueError: If no app is registered for this instance.
"""
instance_url = _normalise(instance_url)
cred = await MastodonAppCredential.find_one(
MastodonAppCredential.instance_url == instance_url
)
if not cred:
raise ValueError(
f"No app registered for {instance_url}. "
"Call get_authorization_url first."
)

def _exchange() -> str:
m = Mastodon(
client_id=cred.client_id,
client_secret=cred.client_secret,
api_base_url=instance_url,
)
token: str = m.log_in(
code=code,
redirect_uri=redirect_uri,
scopes=_SCOPES,
)
return token

token = await asyncio.to_thread(_exchange)
logger.info(f"Exchanged OAuth code for token on {instance_url}")
return token

async def _get_or_register_app(
self, instance_url: str, redirect_uri: str
) -> MastodonAppCredential:
"""Return cached credentials, or register a new app on the instance."""
existing = await MastodonAppCredential.find_one(
MastodonAppCredential.instance_url == instance_url
)
if existing:
return existing

def _register() -> tuple[str, str]:
return Mastodon.create_app(
_APP_NAME,
api_base_url=instance_url,
redirect_uris=redirect_uri,
scopes=_SCOPES,
to_file=None,
)

client_id, client_secret = await asyncio.to_thread(_register)
cred = MastodonAppCredential(
instance_url=instance_url,
client_id=client_id,
client_secret=client_secret,
)
await cred.insert()
logger.info(f"Registered Mastodon OAuth2 app for {instance_url}")
return cred


def _normalise(url: str) -> str:
"""Ensure consistent URL format (https, no trailing slash)."""
url = url.strip().rstrip("/")
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
return url
Loading
Loading