- POST /api/gitea/projects/{owner}/{repo}/upload (binary upload)
- get_file_commits() in GiteaClient
- GET /api/gitea/projects/{owner}/{repo}/commits (per-file history)
- POST .../sync-labels (Gitea labels → FlowDeck tags)
- New file form + upload button in gitea_workspace.html topbar
263 lines
9.7 KiB
Python
263 lines
9.7 KiB
Python
"""Multi-forge OAuth providers — Gitea + GitHub."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from urllib.parse import urlencode
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OAuthProvider(ABC):
|
|
"""Abstract OAuth2 provider interface."""
|
|
|
|
name: str = ""
|
|
icon: str = "🔗"
|
|
|
|
@abstractmethod
|
|
def is_enabled(self) -> bool:
|
|
"""Whether this provider is configured."""
|
|
|
|
@abstractmethod
|
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
|
"""Build the authorization URL."""
|
|
|
|
@abstractmethod
|
|
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
|
"""Exchange authorization code for access token."""
|
|
|
|
@abstractmethod
|
|
async def get_user(self, access_token: str) -> dict | None:
|
|
"""Fetch user profile from the provider."""
|
|
|
|
@abstractmethod
|
|
async def list_repositories(self, access_token: str) -> list[dict]:
|
|
"""List all repositories accessible to this user."""
|
|
|
|
|
|
class GiteaProvider(OAuthProvider):
|
|
"""Gitea OAuth2 provider."""
|
|
|
|
name = "gitea"
|
|
icon = "🔗"
|
|
|
|
def __init__(self, base_url: str, client_id: str, client_secret: str, redirect_uri: str):
|
|
self.base = base_url.rstrip("/")
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self.redirect_uri = redirect_uri
|
|
|
|
def is_enabled(self) -> bool:
|
|
return bool(self.client_id and self.client_secret)
|
|
|
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
|
params = {
|
|
"client_id": self.client_id,
|
|
"redirect_uri": redirect_uri or self.redirect_uri,
|
|
"response_type": "code",
|
|
"state": state,
|
|
}
|
|
if force_login:
|
|
# Gitea supports prompt=login param (undocumented but works)
|
|
# If not, fallback: add _force= timestamp cache-buster
|
|
params["_force"] = str(int(time.time()))
|
|
return f"{self.base}/login/oauth/authorize?" + urlencode(params)
|
|
|
|
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
|
url = f"{self.base}/login/oauth/access_token"
|
|
data = {
|
|
"client_id": self.client_id,
|
|
"client_secret": self.client_secret,
|
|
"code": code,
|
|
"grant_type": "authorization_code",
|
|
"redirect_uri": redirect_uri or self.redirect_uri,
|
|
}
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
r = await client.post(url, json=data, headers={"Accept": "application/json"})
|
|
if r.status_code != 200:
|
|
logger.error("Gitea token exchange failed: %s", r.text)
|
|
return None
|
|
return r.json()
|
|
|
|
async def get_user(self, access_token: str) -> dict | None:
|
|
url = f"{self.base}/api/v1/user"
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
r = await client.get(url, headers={"Authorization": f"token {access_token}"})
|
|
if r.status_code != 200:
|
|
return None
|
|
data = r.json()
|
|
return {
|
|
"login": data.get("login") or data.get("username", ""),
|
|
"full_name": data.get("full_name", ""),
|
|
"email": data.get("email", ""),
|
|
"avatar_url": data.get("avatar_url", ""),
|
|
"provider_id": str(data.get("id", "")),
|
|
}
|
|
|
|
async def list_repositories(self, access_token: str) -> list[dict]:
|
|
repos = []
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
for page in range(1, 6):
|
|
r = await client.get(
|
|
f"{self.base}/api/v1/user/repos",
|
|
headers={"Authorization": f"token {access_token}"},
|
|
params={"page": page, "limit": 50},
|
|
)
|
|
if r.status_code != 200:
|
|
break
|
|
data = r.json()
|
|
if not data:
|
|
break
|
|
for repo in data:
|
|
repos.append({
|
|
"id": str(repo.get("id", "")),
|
|
"name": repo.get("name", ""),
|
|
"full_name": repo.get("full_name", ""),
|
|
"description": repo.get("description", ""),
|
|
"html_url": repo.get("html_url", ""),
|
|
"clone_url": repo.get("clone_url", ""),
|
|
"default_branch": repo.get("default_branch", "main"),
|
|
"language": repo.get("language", ""),
|
|
"updated_at": repo.get("updated_at", ""),
|
|
"private": repo.get("private", False),
|
|
"forge": "gitea",
|
|
})
|
|
return repos
|
|
|
|
|
|
class GitHubProvider(OAuthProvider):
|
|
"""GitHub OAuth2 provider."""
|
|
|
|
name = "github"
|
|
icon = "🐙"
|
|
|
|
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self.redirect_uri = redirect_uri
|
|
self.authorize_url = "https://github.com/login/oauth/authorize"
|
|
self.token_url = "https://github.com/login/oauth/access_token"
|
|
self.api_url = "https://api.github.com"
|
|
|
|
def is_enabled(self) -> bool:
|
|
return bool(self.client_id and self.client_secret)
|
|
|
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
|
return (
|
|
f"{self.authorize_url}?"
|
|
+ urlencode({
|
|
"client_id": self.client_id,
|
|
"redirect_uri": redirect_uri or self.redirect_uri,
|
|
"scope": "repo,user",
|
|
"state": state,
|
|
})
|
|
)
|
|
|
|
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
r = await client.post(
|
|
self.token_url,
|
|
data={
|
|
"client_id": self.client_id,
|
|
"client_secret": self.client_secret,
|
|
"code": code,
|
|
"redirect_uri": self.redirect_uri,
|
|
},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
if r.status_code != 200:
|
|
logger.error("GitHub token exchange failed: %s", r.text)
|
|
return None
|
|
data = r.json()
|
|
if "access_token" not in data:
|
|
return None
|
|
return data
|
|
|
|
async def get_user(self, access_token: str) -> dict | None:
|
|
url = f"{self.api_url}/user"
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
r = await client.get(
|
|
url,
|
|
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
|
)
|
|
if r.status_code != 200:
|
|
return None
|
|
data = r.json()
|
|
return {
|
|
"login": data.get("login", ""),
|
|
"full_name": data.get("name", "") or data.get("login", ""),
|
|
"email": data.get("email", ""),
|
|
"avatar_url": data.get("avatar_url", ""),
|
|
"provider_id": str(data.get("id", "")),
|
|
}
|
|
|
|
async def list_repositories(self, access_token: str) -> list[dict]:
|
|
repos = []
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
for page in range(1, 6):
|
|
r = await client.get(
|
|
f"{self.api_url}/user/repos",
|
|
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
|
params={"page": page, "per_page": 50, "sort": "updated"},
|
|
)
|
|
if r.status_code != 200:
|
|
break
|
|
data = r.json()
|
|
if not data:
|
|
break
|
|
for repo in data:
|
|
repos.append({
|
|
"id": str(repo.get("id", "")),
|
|
"name": repo.get("name", ""),
|
|
"full_name": repo.get("full_name", ""),
|
|
"description": repo.get("description", ""),
|
|
"html_url": repo.get("html_url", ""),
|
|
"clone_url": repo.get("clone_url", ""),
|
|
"default_branch": repo.get("default_branch", "main"),
|
|
"language": repo.get("language", ""),
|
|
"updated_at": repo.get("updated_at", ""),
|
|
"private": repo.get("private", False),
|
|
"forge": "github",
|
|
})
|
|
return repos
|
|
|
|
|
|
# ═══════════ Provider registry ═══════════
|
|
|
|
def get_providers() -> list[OAuthProvider]:
|
|
"""Return all configured OAuth providers."""
|
|
from app.config import settings
|
|
|
|
providers: list[OAuthProvider] = []
|
|
|
|
gitea = GiteaProvider(
|
|
base_url=settings.gitea_url,
|
|
client_id=settings.gitea_oauth_client_id,
|
|
client_secret=settings.gitea_oauth_client_secret,
|
|
redirect_uri=settings.oauth_redirect_uri,
|
|
)
|
|
if gitea.is_enabled():
|
|
providers.append(gitea)
|
|
|
|
github = GitHubProvider(
|
|
client_id=settings.github_oauth_client_id or "",
|
|
client_secret=settings.github_oauth_client_secret or "",
|
|
redirect_uri=settings.oauth_redirect_uri or "",
|
|
)
|
|
if github.is_enabled():
|
|
providers.append(github)
|
|
|
|
return providers
|
|
|
|
|
|
def get_provider(name: str) -> OAuthProvider | None:
|
|
"""Get a specific provider by name."""
|
|
for p in get_providers():
|
|
if p.name == name:
|
|
return p
|
|
return None
|