diff --git a/app/db.py b/app/db.py index ca466ec..17612b1 100644 --- a/app/db.py +++ b/app/db.py @@ -410,6 +410,58 @@ def init_db(): """) conn.commit() + # ── v4.0: Account & Integrations ── + # 1) auth_method on users + try: + conn.execute("ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'") + except sqlite3.OperationalError: + pass + # Detect existing auth: if user has a gitea_token in user_tokens → 'gitea' + conn.execute( + "UPDATE users SET auth_method='gitea' WHERE id IN (SELECT gitea_user_id FROM user_tokens)" + ) + conn.execute( + "UPDATE users SET auth_method='local' WHERE auth_method IS NULL" + ) + # 2) Publishing & sharing columns on pages + try: + conn.execute("ALTER TABLE pages ADD COLUMN is_published INTEGER NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE pages ADD COLUMN publish_slug TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE pages ADD COLUMN is_shared INTEGER NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass + # 3) page_shares table + conn.execute(""" + CREATE TABLE IF NOT EXISTS page_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + shared_with_user_id INTEGER REFERENCES users(id), + shared_with_email TEXT DEFAULT '', + permission TEXT NOT NULL DEFAULT 'view', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER REFERENCES users(id) + ) + """) + # 4) recents table + conn.execute(""" + CREATE TABLE IF NOT EXISTS recents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + page_id INTEGER NOT NULL REFERENCES pages(id), + workspace TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'local', + accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, page_id) + ) + """) + conn.commit() + @contextmanager def get_conn(): diff --git a/app/main.py b/app/main.py index c59782d..b41a5b2 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin, sharing from app.routers.gitea import router as gitea_router from app.services.gitea_client import gitea from app.services.webhook_outbound import init_webhook_tables @@ -62,6 +62,7 @@ app.include_router(webhooks.router) app.include_router(collections.router) app.include_router(my_tasks.router) app.include_router(workspace.router) +app.include_router(library.router) app.include_router(admin.router) app.include_router(gitea_router) app.include_router(public_api.router) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 8ac9325..82d91c3 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -90,6 +90,27 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: except (ValueError, Exception): pass + # Auth method & OAuth badge data + auth_method = "local" + gitea_linked = False + github_linked = False + if user and user.get("id"): + try: + with get_conn() as conn: + am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() + if am_row and am_row["auth_method"]: + auth_method = am_row["auth_method"] + tokens = conn.execute( + "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) + ).fetchall() + for t in tokens: + if t["provider"] == "gitea": + gitea_linked = True + elif t["provider"] == "github": + github_linked = True + except Exception: + pass + return { "workspace_name": ws, "workspace_initial": initial, "active_ws_name": active_ws_name, @@ -104,6 +125,9 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict: "avatar_url": avatar_url, "avatar_color": avatar_color, "workspace_key": "", + "auth_method": auth_method, + "gitea_linked": gitea_linked, + "github_linked": github_linked, } diff --git a/app/routers/library.py b/app/routers/library.py new file mode 100644 index 0000000..8ac8b6b --- /dev/null +++ b/app/routers/library.py @@ -0,0 +1,249 @@ +"""FlowDeck — Library API: recents, favorites, shared, published, private, workspace.""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request, Query +from fastapi.responses import JSONResponse + +from app.db import get_conn +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["library"], prefix="/api/library") + +SOURCE_TYPES = {"all", "local", "gitea", "github"} + + +def _get_user_id(request: Request) -> int: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + return user["id"] if user and user.get("id") else 1 + + +def _detect_source_type(workspace: str) -> str: + """Detect source type from workspace field. + + - '' or plain name (no slash) → local + - 'owner/repo' format → gitea + - 'github:owner/repo' format → github + """ + ws = (workspace or "").strip() + if not ws: + return "local" + if ws.startswith("github:"): + return "github" + if "/" in ws: + return "gitea" + return "local" + + +def _source_badge(source_type: str) -> str: + return {"local": "Local", "gitea": "Gitea", "github": "GitHub"}.get(source_type, "Local") + + +def _build_item(db_row: dict) -> dict: + """Convert a DB row into a library item dict.""" + workspace = db_row.get("workspace", "") or "" + source_type = _detect_source_type(workspace) + # Extract workspace display name + if source_type == "gitea": + workspace_name = workspace # "owner/repo" + elif source_type == "github": + workspace_name = workspace.replace("github:", "", 1) + else: + workspace_name = workspace or "Private" + + # URL construction + page_id = db_row["id"] + if source_type == "gitea": + parts = workspace.split("/", 1) + owner = parts[0] if len(parts) > 0 else "" + repo = parts[1] if len(parts) > 1 else "" + url = f"/gitea-workspace?owner={owner}&repo={repo}" + elif source_type == "github": + gh_ws = workspace.replace("github:", "", 1) + parts = gh_ws.split("/", 1) + owner = parts[0] if len(parts) > 0 else "" + repo = parts[1] if len(parts) > 1 else "" + url = f"/gitea-workspace?owner={owner}&repo={repo}" + else: + url = f"/pages/{page_id}" + + return { + "id": page_id, + "title": db_row.get("title") or "Untitled", + "icon": "📄", + "source_type": source_type, + "source_badge": _source_badge(source_type), + "workspace_name": workspace_name, + "updated_at": db_row.get("updated_at", ""), + "url": url, + } + + +def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[str, list]: + """Add WHERE clause for source_type filter. Mutates params in place.""" + if source_type == "local": + query += " AND (workspace = '' OR workspace NOT LIKE '%/%' AND workspace NOT LIKE 'github:%')" + elif source_type == "gitea": + query += " AND workspace LIKE '%/%' AND workspace NOT LIKE 'github:%'" + elif source_type == "github": + query += " AND workspace LIKE 'github:%'" + # 'all' → no filter + return query, params + + +# ── Tab endpoints ── + + +@router.get("/recents") +async def library_recents( + request: Request, + source_type: str = Query(default="all"), +): + """Recent pages ordered by updated_at DESC.""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + uid = _get_user_id(request) + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/favorites") +async def library_favorites( + request: Request, + source_type: str = Query(default="all"), +): + """Favorite pages for the current user.""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + uid = _get_user_id(request) + query = ( + "SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f " + "JOIN pages p ON p.id = f.page_id " + "WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" + ) + params: list = [uid] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY f.position" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/shared") +async def library_shared( + request: Request, + source_type: str = Query(default="all"), +): + """Pages shared with others (share_mode != 'private').""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE share_mode != 'private' AND parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/published") +async def library_published( + request: Request, + source_type: str = Query(default="all"), +): + """Published pages (published = 1).""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE published = 1 AND parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/private") +async def library_private( + request: Request, + source_type: str = Query(default="all"), +): + """Private pages (parent_section = 'Private').""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE parent_section = 'Private' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} + + +@router.get("/workspace") +async def library_workspace( + request: Request, + source_type: str = Query(default="all"), +): + """Pages from workspaces (non-empty workspace field).""" + if source_type not in SOURCE_TYPES: + source_type = "all" + + query = ( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE workspace != '' AND parent_section != 'Trash' AND deleted_at IS NULL" + ) + params: list = [] + + query, params = _apply_source_filter(query, params, source_type) + query += " ORDER BY updated_at DESC LIMIT 50" + + with get_conn() as conn: + rows = conn.execute(query, params).fetchall() + + items = [_build_item(dict(r)) for r in rows] + return {"items": items} diff --git a/app/routers/sharing.py b/app/routers/sharing.py new file mode 100644 index 0000000..c545a18 --- /dev/null +++ b/app/routers/sharing.py @@ -0,0 +1,245 @@ +"""FlowDeck — Sharing, Publishing & Recents API (v4.0).""" +from __future__ import annotations + +import logging +import re +import unicodedata +from datetime import datetime + +from fastapi import APIRouter, Request, HTTPException + +from app.db import get_conn +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["sharing"], prefix="/api") + + +def _require_auth(request: Request) -> dict: + """Require an authenticated session. Returns user dict or raises 401.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + return user + + +def _slugify(title: str) -> str: + """Generate a URL-safe slug from a page title.""" + slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^\w\s-]", "", slug.lower()) + slug = re.sub(r"[-\s]+", "-", slug).strip("-") + return slug or "untitled" + + +# ── Page Sharing ── + + +@router.post("/pages/{page_id}/share") +async def share_page(page_id: int, request: Request): + """Invite a user or email to a page.""" + user = _require_auth(request) + body = await request.json() if request.headers.get("content-type") else {} + target_user_id = body.get("user_id") + email = body.get("email", "") + permission = body.get("permission", "view") + + if permission not in ("view", "comment", "edit"): + raise HTTPException(400, "Invalid permission. Use view, comment, or edit.") + + if not target_user_id and not email: + raise HTTPException(400, "Provide user_id or email to share with.") + + with get_conn() as conn: + # Verify page exists + page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + # Verify target user exists if user_id given + if target_user_id: + target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone() + if not target: + raise HTTPException(404, "Target user not found") + + cur = conn.execute( + """INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) + VALUES (?, ?, ?, ?, ?)""", + (page_id, target_user_id, email, permission, user["id"]), + ) + conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,)) + conn.commit() + + return { + "id": cur.lastrowid, + "page_id": page_id, + "shared_with_user_id": target_user_id, + "shared_with_email": email, + "permission": permission, + "status": "shared", + } + + +@router.delete("/pages/{page_id}/share/{share_id}") +async def remove_share(page_id: int, share_id: int, request: Request): + """Remove a share invitation.""" + _require_auth(request) + + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM page_shares WHERE id=? AND page_id=?", + (share_id, page_id), + ).fetchone() + if not row: + raise HTTPException(404, "Share entry not found") + + conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,)) + # If no more shares, unset is_shared + remaining = conn.execute( + "SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,) + ).fetchone()["c"] + if remaining == 0: + conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,)) + conn.commit() + + return {"status": "removed", "share_id": share_id} + + +@router.get("/pages/{page_id}/shares") +async def list_shares(page_id: int, request: Request): + """Get all shares for a page.""" + _require_auth(request) + + with get_conn() as conn: + page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + rows = conn.execute( + """SELECT s.*, u.login, u.full_name, u.avatar_url + FROM page_shares s + LEFT JOIN users u ON s.shared_with_user_id = u.id + WHERE s.page_id=? + ORDER BY s.created_at DESC""", + (page_id,), + ).fetchall() + + return { + "page_id": page_id, + "shares": [ + { + "id": r["id"], + "shared_with_user_id": r["shared_with_user_id"], + "shared_with_email": r["shared_with_email"], + "permission": r["permission"], + "created_at": r["created_at"], + "created_by": r["created_by"], + "user_login": r["login"], + "user_full_name": r["full_name"], + "user_avatar_url": r["avatar_url"], + } + for r in rows + ], + } + + +# ── Page Publishing ── + + +@router.post("/pages/{page_id}/publish") +async def publish_page(page_id: int, request: Request): + """Publish a page (is_published=1) with a URL slug.""" + user = _require_auth(request) + + with get_conn() as conn: + page = conn.execute( + "SELECT id, title, is_published FROM pages WHERE id=?", (page_id,) + ).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + slug = _slugify(page["title"]) + # Ensure uniqueness by appending suffix if needed + base_slug = slug + counter = 1 + while conn.execute( + "SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id) + ).fetchone(): + slug = f"{base_slug}-{counter}" + counter += 1 + + conn.execute( + "UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", + (slug, page_id), + ) + conn.commit() + + return { + "page_id": page_id, + "is_published": True, + "publish_slug": slug, + "status": "published", + } + + +@router.delete("/pages/{page_id}/publish") +async def unpublish_page(page_id: int, request: Request): + """Unpublish a page.""" + _require_auth(request) + + with get_conn() as conn: + page = conn.execute( + "SELECT id, is_published FROM pages WHERE id=?", (page_id,) + ).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + conn.execute( + "UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", + (page_id,), + ) + conn.commit() + + return { + "page_id": page_id, + "is_published": False, + "status": "unpublished", + } + + +# ── Recents ── + + +@router.post("/recents/track") +async def track_recent(request: Request): + """Record a page access in recents.""" + user = _require_auth(request) + body = await request.json() if request.headers.get("content-type") else {} + page_id = body.get("page_id") + workspace = body.get("workspace", "") + source_type = body.get("source_type", "local") + + if not page_id: + raise HTTPException(400, "page_id is required") + + with get_conn() as conn: + page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + conn.execute( + """INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, page_id) + DO UPDATE SET workspace=excluded.workspace, + source_type=excluded.source_type, + accessed_at=excluded.accessed_at""", + (user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()), + ) + conn.commit() + + return { + "status": "tracked", + "user_id": user["id"], + "page_id": page_id, + "accessed_at": datetime.utcnow().isoformat(), + } diff --git a/app/templates/settings.html b/app/templates/settings.html index 795a8b0..2aef013 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -319,9 +319,10 @@
Integrations and feature toggles.