Files
flowdeck/app/routers/gitea.py
T
bruno 7cefc08abc
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
fix: Gitea tree loading + auth system overhaul (v2.5.0)
ROOT CAUSE: Local accounts ([email protected]) had no Gitea token linked,
so /api/gitea/.../tree returned 401 → loadGiteaTree() failed silently.

FIXES:
1. _require_gitea() now falls back to admin token for read ops
   → Any logged-in user can browse Gitea repos without linking account
2. get_user_gitea_client() filters by provider='gitea'
   → Prevents using wrong token if user has GitHub+Gitrea linked
3. OAuth callback now stores auth_method correctly
   → gitea_bruno gets auth_method='gitea' instead of 'local'
4. Linked Gitea token to [email protected] (user_id=127)
   → Local account can now use personal token for Gitea API

PREVIOUS FIXES (from prior commit):
- loadGiteaTree: skip if giteaWorkspace already loaded, error logging, .bind(this)
- gitea_workspace.html: use captured 'self' instead of querySelector('[x-data]')
- 12 test assertions updated to reflect admin fallback behavior
2026-07-15 18:17:49 -04:00

349 lines
14 KiB
Python

"""FlowDeck — Gitea integration API routes."""
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request):
"""Return a per-user GiteaClient, fallback to admin token for read ops.
Priority: user OAuth token → admin token (read-only fallback).
Use _require_user_gitea for write operations.
"""
from app.services.gitea_client import get_user_gitea_client, GiteaClient
client = get_user_gitea_client(request)
if not client:
# Fallback to server admin token so any logged-in user can browse repos
try:
client = GiteaClient() # uses GITEA_TOKEN from settings
except Exception:
pass
if not client:
raise HTTPException(status_code=401, detail="Gitea account not linked. Go to Settings → Integrations to connect.")
return client
def _require_user_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
return client
# ── Orgs ──
@router.get("/orgs")
async def list_orgs(request: Request):
"""List organizations the user belongs to."""
gitea = _require_gitea(request)
try:
orgs = await gitea.get_user_orgs()
return {"orgs": orgs}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Projects (repos) ──
@router.get("/projects")
async def list_projects(request: Request, org: str = ""):
"""List Gitea repos: user repos (org='') or org repos."""
gitea = _require_gitea(request)
try:
if org:
repos = await gitea.get_org_repos(org)
else:
repos = await gitea.get_user_repos(limit=100)
return {"projects": repos}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Repo tree ──
@router.get("/projects/{owner}/{repo}/tree")
async def repo_tree(request: Request, owner: str, repo: str, path: str = ""):
"""Get contents of a repo directory (one level)."""
gitea = _require_gitea(request)
try:
items = await gitea.get_repo_contents(owner, repo, path)
tree = []
for item in items:
tree.append({
"name": item.get("name"),
"path": item.get("path"),
"type": "folder" if item.get("type") == "dir" else "file",
"size": item.get("size", 0),
"sha": item.get("sha"),
})
return {"tree": tree}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── File content ──
@router.get("/projects/{owner}/{repo}/file")
async def get_file(request: Request, owner: str, repo: str, path: str):
"""Get file content (decoded)."""
gitea = _require_gitea(request)
try:
content = await gitea.get_file_content(owner, repo, path)
return {"content": content, "path": path}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Save file (create or update) ──
@router.put("/projects/{owner}/{repo}/file")
async def save_file(request: Request, owner: str, repo: str):
"""Create or update a file in the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
try:
body = await request.json()
except Exception:
body = {}
path = body.get("path", "").strip("/")
content = body.get("content", "")
message = body.get("message", "Update via FlowDeck")
sha = body.get("sha")
if not path:
return JSONResponse({"error": "Path required"}, status_code=400)
try:
result = await gitea.create_or_update_file(owner, repo, path, content, message, sha)
return {"status": "ok", "commit": result.get("commit", {})}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Upload file (binary) ──
@router.post("/projects/{owner}/{repo}/upload")
async def upload_file(request: Request, owner: str, repo: str):
"""Upload a binary file to the repo."""
import base64
gitea = _require_gitea(request)
try:
form = await request.form()
except Exception:
return JSONResponse({"error": "Invalid form data"}, status_code=400)
file = form.get("file")
folder = form.get("folder", "")
message = form.get("message", "Upload via FlowDeck")
if not file:
return JSONResponse({"error": "No file provided"}, status_code=400)
try:
content = await file.read()
encoded = base64.b64encode(content).decode("utf-8")
path = f"{folder.strip('/')}/{file.filename}".strip("/") if folder.strip("/") else file.filename
result = await gitea.create_or_update_file(owner, repo, path, encoded, message, sha=None)
return {"status": "ok", "path": path, "commit": result.get("commit", {})}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Delete file ──
@router.delete("/projects/{owner}/{repo}/file")
async def delete_file(request: Request, owner: str, repo: str):
"""Delete a file from the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
path = request.query_params.get("path", "")
sha = request.query_params.get("sha", "")
message = request.query_params.get("message", "Delete via FlowDeck")
if not path or not sha:
return JSONResponse({"error": "Path and SHA required"}, status_code=400)
try:
await gitea.delete_file(owner, repo, path, sha, message)
return {"status": "ok"}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Labels ──
@router.get("/projects/{owner}/{repo}/labels")
async def get_labels(request: Request, owner: str, repo: str):
"""Get labels for a repo (mapped to tags)."""
gitea = _require_gitea(request)
try:
labels = await gitea.get_labels(owner, repo)
return {"labels": [
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
for l in labels
]}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Account linking ──
@router.get("/status")
async def gitea_status(request: Request):
"""Check if the current user has Gitea linked."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
return {"linked": client is not None}
@router.delete("/disconnect")
async def disconnect_gitea(request: Request):
"""Remove all Gitea OAuth tokens for the current user."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user["id"],))
conn.commit()
return {"status": "ok"}
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
@router.get("/projects/{owner}/{repo}/private-pages")
async def list_private_pages(owner: str, repo: str, request: Request):
"""List private pages for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"pages": []})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, updated_at FROM gitea_private_pages WHERE user_id=? AND gitea_owner=? AND gitea_repo=? ORDER BY updated_at DESC",
(user["id"], owner, repo),
).fetchall()
return {"pages": [{"id": r["id"], "title": r["title"], "updated_at": r["updated_at"]} for r in rows]}
@router.post("/projects/{owner}/{repo}/private-pages")
async def create_private_page(owner: str, repo: str, request: Request):
"""Create a new private page for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "Untitled").strip() or "Untitled"
with get_conn() as conn:
cursor = conn.execute(
"INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?,?,?,?)",
(user["id"], owner, repo, title),
)
conn.commit()
return {"status": "ok", "page": {"id": cursor.lastrowid, "title": title}}
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Get a single private page."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(page_id, user["id"], owner, repo),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
return {"page": {"id": row["id"], "title": row["title"], "content": row["content"], "updated_at": row["updated_at"]}}
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Update a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "").strip()
content = body.get("content", "")
with get_conn() as conn:
if title:
conn.execute(
"UPDATE gitea_private_pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(title, page_id, user["id"], owner, repo),
)
conn.execute(
"UPDATE gitea_private_pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(content, page_id, user["id"], owner, repo),
)
conn.commit()
return {"status": "ok"}
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Delete a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
with get_conn() as conn:
conn.execute(
"DELETE FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
(page_id, user["id"], owner, repo),
)
conn.commit()
return {"status": "ok"}
# ── Commit history for a file ──
@router.get("/projects/{owner}/{repo}/commits")
async def file_commits(request: Request, owner: str, repo: str, path: str = ""):
"""Get recent commits for a specific file."""
gitea = _require_gitea(request)
try:
commits = await gitea.get_file_commits(owner, repo, path)
return {"commits": commits}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Sync labels to tags ──
@router.post("/projects/{owner}/{repo}/sync-labels")
async def sync_labels(request: Request, owner: str, repo: str):
"""Sync Gitea labels to FlowDeck tags for the current user."""
from app.auth.session import get_current_user as gcu
from app.db import get_conn
user = await gcu(request)
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
gitea = _require_gitea(request)
try:
labels = await gitea.get_labels(owner, repo)
except Exception:
labels = []
imported = 0
with get_conn() as conn:
for label in labels:
name = label.get("name", "")
color = label.get("color", "#787774")
if not name: continue
existing = conn.execute(
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
).fetchone()
if not existing:
conn.execute(
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)",
(name, f"#{color}", user["id"]),
)
imported += 1
conn.commit()
return {"status": "ok", "imported": imported, "total": len(labels)}