diff --git a/.env.example b/.env.example
index 636aeab..aadf6f9 100644
--- a/.env.example
+++ b/.env.example
@@ -32,6 +32,18 @@ DATABASE_URL=sqlite:////data/flowdeck.db
SYNC_INTERVAL=60
GITEA_CACHE_TTL=30
+# ── Backups (v5.2.0) ──
+# Sauvegarde automatique quotidienne du fichier SQLite (fichiers datés).
+BACKUP_ENABLED=true
+BACKUP_DIR=/data/backups
+BACKUP_INTERVAL_HOURS=24
+BACKUP_KEEP=30
+
+# ── Forge projects sync (v5.2.0) ──
+# Rafraîchissement périodique de la table `projects` depuis les forges connectées.
+PROJECT_SYNC_ENABLED=true
+PROJECT_SYNC_INTERVAL_HOURS=1
+
# ── Email notifications (v4.9.0) ──
# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app).
SMTP_HOST=
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..57c1e04
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,43 @@
+{
+ "root": true,
+ "env": {
+ "browser": true,
+ "es2022": true
+ },
+ "parserOptions": {
+ "ecmaVersion": 2022,
+ "sourceType": "script"
+ },
+ "globals": {
+ "Alpine": "readonly",
+ "htmx": "readonly",
+ "Sortable": "readonly",
+ "window": "readonly",
+ "document": "readonly",
+ "localStorage": "readonly",
+ "confirm": "readonly",
+ "fetch": "readonly",
+ "navigator": "readonly",
+ "setTimeout": "readonly",
+ "setInterval": "readonly",
+ "history": "readonly",
+ "Location": "readonly",
+ "URLSearchParams": "readonly",
+ "location": "readonly"
+ },
+ "rules": {
+ "no-unused-vars": ["warn", { "args": "none" }],
+ "no-undef": "error",
+ "no-extra-semi": "warn",
+ "no-empty": "warn"
+ },
+ "overrides": [
+ {
+ "files": ["static/js/**/*.js"],
+ "rules": {
+ "no-undef": "warn"
+ }
+ }
+ ],
+ "ignorePatterns": ["static/js/*.min.js", "static/js/vendor/**"]
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 7606e8c..a3f06ff 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,24 @@
-FROM python:3.12-slim
+# ═══════════════════════════════════════════════════════════
+# FlowDeck — multi-stage Docker build (v5.2.0)
+# Stage 1 "builder": build Python wheels once.
+# Stage 2 "runtime": minimal image with WeasyPrint system libs.
+# ═══════════════════════════════════════════════════════════
+FROM python:3.12-slim AS builder
WORKDIR /app
+COPY requirements.txt .
+RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
+
+# ── runtime stage ───────────────────────────────────────────
+FROM python:3.12-slim AS runtime
+
+WORKDIR /app
+
+# WeasyPrint PDF: text layout (pango/harfbuzz), image decoding, fonts,
+# colour emoji support. curl = healthcheck.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
- # WeasyPrint PDF: text layout (pango/harfbuzz), image decoding, fonts,
- # colour emoji support.
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
@@ -16,16 +29,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
+COPY --from=builder /wheels /wheels
+RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
-RUN mkdir -p /data
+RUN mkdir -p /data /data/backups
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/api/health || exit 1
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
\ No newline at end of file
diff --git a/VERSION b/VERSION
index b3d91f9..9266671 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-5.9.0
+5.9.1
\ No newline at end of file
diff --git a/app/auth/session.py b/app/auth/session.py
index 858e040..5b34467 100644
--- a/app/auth/session.py
+++ b/app/auth/session.py
@@ -1,26 +1,43 @@
"""FlowDeck — Session management with signed cookies."""
from __future__ import annotations
-import json
-from datetime import datetime, timedelta
+import logging
+from datetime import datetime
+from uuid import uuid4
-from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
+from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
+logger = logging.getLogger(__name__)
+
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
class SessionManager:
- """Manages user sessions via signed cookies."""
+ """Manages user sessions via signed cookies (v5.2.0: revocable).
+
+ Each cookie embeds a ``sid`` referencing a row in ``user_sessions``.
+ Revoking that row instantly invalidates the cookie (checked in
+ ``decode_session``). Legacy cookies without a ``sid`` stay valid.
+ """
@staticmethod
- def create_session(user_data: dict) -> str:
- """Create a signed session cookie value."""
+ def create_session(user_data: dict, request=None) -> str:
+ """Create a signed session cookie value.
+
+ ``request`` is optional — when provided the session is recorded in the
+ ``user_sessions`` table (ip + user agent) and becomes revocable.
+ """
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
}
+ user_id = user_data.get("id")
+ if user_id:
+ sid = str(uuid4())
+ payload["sid"] = sid
+ _record_session(sid, user_id, request)
return _serializer.dumps(payload)
@staticmethod
@@ -28,10 +45,65 @@ class SessionManager:
"""Decode and validate a session cookie. Returns user data or None."""
try:
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
- return payload.get("user")
except (BadSignature, SignatureExpired):
return None
+ sid = payload.get("sid") or ""
+ if sid and not _session_active(sid):
+ # Revoked or deleted session → treat as logged out.
+ return None
+ if sid:
+ _touch_session(sid)
+ return payload.get("user")
+
+ @staticmethod
+ def session_id(cookie: str) -> str | None:
+ """Return the session id embedded in a cookie (or None)."""
+ try:
+ payload = _serializer.loads(cookie, max_age=86400 * 7)
+ return payload.get("sid")
+ except (BadSignature, SignatureExpired):
+ return None
+
+ @staticmethod
+ def list_sessions(user_id: int) -> list[dict]:
+ """All recorded sessions for a user (for the Settings UI)."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT id, ip_address, user_agent, created_at, last_seen_at, revoked "
+ "FROM user_sessions WHERE user_id=? ORDER BY last_seen_at DESC",
+ (user_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ @staticmethod
+ def revoke_session(sid: str) -> bool:
+ """Revoke a session row. Returns True if a row was updated."""
+ from app.db import get_conn
+ with get_conn() as conn:
+ cur = conn.execute(
+ "UPDATE user_sessions SET revoked=1 WHERE id=? AND revoked=0", (sid,)
+ )
+ conn.commit()
+ return cur.rowcount > 0
+
+ @staticmethod
+ def refresh_session(cookie: str, user_data: dict, request=None) -> str:
+ """Re-sign a cookie keeping its session id (used after profile edits)."""
+ sid = SessionManager.session_id(cookie) if cookie else None
+ payload = {
+ "user": user_data,
+ "created_at": datetime.utcnow().isoformat(),
+ }
+ user_id = user_data.get("id")
+ if user_id:
+ if sid is None:
+ sid = str(uuid4())
+ _record_session(sid, user_id, request)
+ payload["sid"] = sid
+ return _serializer.dumps(payload)
+
@staticmethod
def store_token(user_id: int, gitea_token: str) -> None:
"""Store a user's Gitea OAuth token in SQLite."""
@@ -58,10 +130,53 @@ class SessionManager:
return row["gitea_token"] if row else None
+def _record_session(sid: str, user_id: int, request) -> None:
+ ip = ""
+ ua = ""
+ if request is not None:
+ ip = request.client.host if getattr(request, "client", None) else ""
+ ua = (request.headers.get("user-agent", "") or "")[:500]
+ try:
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT INTO user_sessions (id, user_id, ip_address, user_agent) VALUES (?, ?, ?, ?)",
+ (sid, user_id, ip, ua),
+ )
+ conn.commit()
+ except Exception as exc: # table may not exist in very old installs
+ logger.debug("session record skipped: %s", exc)
+
+
+def _session_active(sid: str) -> bool:
+ try:
+ from app.db import get_conn
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT revoked FROM user_sessions WHERE id=?", (sid,)
+ ).fetchone()
+ return bool(row and not row["revoked"])
+ except Exception:
+ # No table / DB unavailable → keep the cookie valid (fail-open-safe).
+ return True
+
+
+def _touch_session(sid: str) -> None:
+ try:
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute(
+ "UPDATE user_sessions SET last_seen_at=CURRENT_TIMESTAMP WHERE id=? AND revoked=0",
+ (sid,),
+ )
+ conn.commit()
+ except Exception:
+ pass
+
+
# FastAPI dependency
async def get_current_user(request) -> dict | None:
"""FastAPI dependency: extract current user from session cookie."""
- from fastapi import Request
session = request.cookies.get("flowdeck_session")
if session:
return SessionManager.decode_session(session)
diff --git a/app/config.py b/app/config.py
index bb4d2e4..3f1293c 100644
--- a/app/config.py
+++ b/app/config.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
+
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -49,6 +50,16 @@ class Settings(BaseSettings):
sync_interval: int = 60
gitea_cache_ttl: int = 30
+ # Backup (v5.2.0) — scheduled daily snapshot of the SQLite file
+ backup_enabled: bool = True
+ backup_dir: str = "/data/backups"
+ backup_interval_hours: int = 24
+ backup_keep: int = 30
+
+ # Forge projects sync (v5.2.0) — periodic refresh of `projects` table
+ project_sync_enabled: bool = True
+ project_sync_interval_hours: int = 1
+
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
# email notifications are skipped (only in-app notifications are delivered).
smtp_host: str = ""
diff --git a/app/main.py b/app/main.py
index 58cdc76..552772c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,27 +1,47 @@
"""FlowDeck — Kanban léger intégré à Gitea."""
from __future__ import annotations
-import logging
import asyncio
+import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
-from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
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, library, public_api, admin, sharing, sidebar_config, export, agent, search
-from app.routers.notifications import router as notifications_router
+from app.routers import (
+ admin,
+ agent,
+ api,
+ auth,
+ board,
+ collections,
+ dashboard,
+ export,
+ library,
+ my_tasks,
+ notes,
+ onboarding,
+ projects,
+ public_api,
+ search,
+ security,
+ sharing,
+ sidebar_config,
+ webhooks,
+ workspace,
+)
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
-from app.routers.realtime import router as realtime_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
-from app.services.gitea_client import gitea
+from app.routers.notifications import router as notifications_router
+from app.routers.realtime import router as realtime_router
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -53,25 +73,30 @@ async def lifespan(_app: FastAPI):
from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler())
- logger.info("FlowDeck v5.9.0 started on port %d", settings.app_port)
+ # ── Backups (v5.2.0): automatic daily SQLite snapshot ──
+ from app.services.backup import backup_scheduler
+ backup_task = asyncio.create_task(backup_scheduler())
+
+ # ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
+ from app.services.projects import project_sync_scheduler
+ projects_task = asyncio.create_task(project_sync_scheduler())
+
+ logger.info("FlowDeck v5.9.1 started on port %d", settings.app_port)
try:
yield
finally:
- scheduler_task.cancel()
- automation_task.cancel()
- try:
- await scheduler_task
- except asyncio.CancelledError:
- pass
- try:
- await automation_task
- except asyncio.CancelledError:
- pass
+ for task in (scheduler_task, automation_task, backup_task, projects_task):
+ task.cancel()
+ for task in (scheduler_task, automation_task, backup_task, projects_task):
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
app = FastAPI(
title="FlowDeck",
- version="5.9.0",
+ version="5.9.1",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -87,6 +112,8 @@ app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(board.router)
app.include_router(notes.router)
+app.include_router(projects.router)
+app.include_router(projects.backups_router)
app.include_router(api.router)
app.include_router(webhooks.router)
app.include_router(collections.router)
@@ -106,6 +133,8 @@ app.include_router(collaboration_router)
app.include_router(realtime_router)
app.include_router(agent.router)
app.include_router(search.router)
+app.include_router(security.router)
+app.include_router(onboarding.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -129,8 +158,9 @@ async def pwa_manifest():
@app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
- from fastapi.responses import JSONResponse
import secrets
+
+ from fastapi.responses import JSONResponse
token = secrets.token_hex(32)
response = JSONResponse({"csrf_token": token})
response.set_cookie(
diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py
index 4ce46a6..2764cc2 100644
--- a/app/middleware/csrf.py
+++ b/app/middleware/csrf.py
@@ -4,8 +4,8 @@ from __future__ import annotations
import secrets
from starlette.middleware.base import BaseHTTPMiddleware
-from starlette.responses import JSONResponse
from starlette.requests import Request
+from starlette.responses import JSONResponse
class CSRFMiddleware(BaseHTTPMiddleware):
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
- EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations"}
+ EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
diff --git a/app/migrations.py b/app/migrations.py
index 808d152..91c4fcb 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -14,7 +14,7 @@ from __future__ import annotations
import logging
import sqlite3
-from typing import Callable, List, Tuple
+from typing import Callable
logger = logging.getLogger(__name__)
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
BASELINE_VERSION = 1
# (version, name, apply_fn). Kept sorted by version at registration time.
-MIGRATIONS: List[Tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
+MIGRATIONS: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
def register(version: int, name: str) -> Callable:
@@ -224,6 +224,82 @@ def _migration_automations(conn: sqlite3.Connection) -> None:
)
+@register(6, "v5.2.0: api tokens, user sessions, projects")
+def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
+ """v5.2.0 (Security & Forge): per-user API tokens, revocable sessions and
+ the forge-agnostic ``projects`` table.
+
+ ``api_tokens`` — per-user bearer tokens (sha256-stored), revocable,
+ powering the public API (/api/v1) and Settings UI.
+ ``user_sessions`` — one row per signed session cookie; revocation here
+ instantly kills the corresponding cookie.
+ ``projects`` — normalized project list across forges (builtin/gitea/
+ github) + last sync timestamp for the periodic cron.
+ """
+ _pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
+ if "id" not in _pcols:
+ conn.execute(
+ """
+ CREATE TABLE api_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL DEFAULT 'API token',
+ token_hash TEXT NOT NULL UNIQUE,
+ token_prefix TEXT NOT NULL DEFAULT '',
+ last_used_at TIMESTAMP,
+ revoked INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
+ )
+
+ _scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
+ if "id" not in _scols:
+ conn.execute(
+ """
+ CREATE TABLE user_sessions (
+ id TEXT PRIMARY KEY, -- session id (cookie payload)
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ ip_address TEXT DEFAULT '',
+ user_agent TEXT DEFAULT '',
+ last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ revoked INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
+ )
+
+ _projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
+ if "id" not in _projcols:
+ conn.execute(
+ """
+ CREATE TABLE projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ proj_type TEXT NOT NULL DEFAULT 'builtin', -- builtin | gitea | github
+ owner TEXT NOT NULL DEFAULT '',
+ forge_id TEXT DEFAULT '',
+ clone_url TEXT DEFAULT '',
+ default_branch TEXT DEFAULT '',
+ language TEXT DEFAULT '',
+ description TEXT DEFAULT '',
+ last_synced_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(proj_type, owner, name)
+ )
+ """
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(proj_type, last_synced_at)"
+ )
+
+
@register(4, "database templates (icon) + property validation")
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
@@ -244,4 +320,4 @@ def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
VALUES (?, ?, ?, ?)""",
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
__import__("json").dumps(tpl.get("schema", []))),
- )
\ No newline at end of file
+ )
diff --git a/app/models/requests.py b/app/models/requests.py
index 59aa9e2..bf9daa7 100644
--- a/app/models/requests.py
+++ b/app/models/requests.py
@@ -1,14 +1,11 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
-from typing import Optional
-
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
-
# ── File Save ────────────────────────────────────────────────
class FileSaveRequest(BaseModel):
@@ -16,7 +13,7 @@ class FileSaveRequest(BaseModel):
path: str = Field(..., min_length=1, description="File path in the repository")
content: str = Field(..., description="File content (UTF-8 encoded)")
message: str = Field(default="Update via FlowDeck", description="Commit message")
- sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
+ sha: str | None = Field(default=None, description="SHA of the file being updated (required for updates)")
@model_validator(mode="after")
def validate_path_extension(self):
@@ -34,10 +31,10 @@ class UploadValidationResult(BaseModel):
size: int
extension: str
valid: bool
- error: Optional[str] = None
+ error: str | None = None
-def validate_upload_request(file: UploadFile) -> Optional[str]:
+def validate_upload_request(file: UploadFile) -> str | None:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
@@ -66,12 +63,12 @@ class IssueCreateRequest(BaseModel):
class IssueUpdateRequest(BaseModel):
"""Request model for updating a Gitea issue (partial update)."""
- title: Optional[str] = Field(default=None, max_length=500)
- body: Optional[str] = Field(default=None)
- state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
- labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
- milestone: Optional[str] = Field(default=None)
- assignee: Optional[str] = Field(default=None)
+ title: str | None = Field(default=None, max_length=500)
+ body: str | None = Field(default=None)
+ state: str | None = Field(default=None, pattern=r"^(open|closed)$")
+ labels: str | None = Field(default=None, description="Comma-separated label IDs")
+ milestone: str | None = Field(default=None)
+ assignee: str | None = Field(default=None)
# ── Card Move ────────────────────────────────────────────────
diff --git a/app/models/responses.py b/app/models/responses.py
index a5d339c..da1dd16 100644
--- a/app/models/responses.py
+++ b/app/models/responses.py
@@ -1,7 +1,7 @@
"""FlowDeck — Standardized response models."""
from __future__ import annotations
-from typing import Any, Optional
+from typing import Any
from pydantic import BaseModel
@@ -13,7 +13,7 @@ class ErrorResponse(BaseModel):
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
"""
error: str
- detail: Optional[str] = None
+ detail: str | None = None
model_config = {
"json_schema_extra": {
@@ -29,7 +29,7 @@ class SuccessResponse(BaseModel):
SuccessResponse(status="ok", data={"issue_id": 42})
"""
status: str = "ok"
- data: Optional[dict[str, Any]] = None
+ data: dict[str, Any] | None = None
model_config = {
"json_schema_extra": {
diff --git a/app/routers/admin.py b/app/routers/admin.py
index 4b8d9fa..6846189 100644
--- a/app/routers/admin.py
+++ b/app/routers/admin.py
@@ -1,5 +1,5 @@
"""FlowDeck — Admin API: users, roles, stats, audit."""
-from fastapi import APIRouter, Request, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["admin"], prefix="/api/admin")
@@ -48,9 +48,9 @@ async def list_users(_admin=Depends(admin_required)):
@router.post("/users")
async def create_user(request: Request, _admin=Depends(admin_required)):
"""Create a new user (admin only)."""
+
from app.db import get_conn
from app.password_utils import hash_password
- import json
try:
body = await request.json()
except Exception:
@@ -80,9 +80,9 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
@router.put("/users/{user_id:int}")
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
"""Update a user: name, email, password, admin status, active status."""
+
from app.db import get_conn
from app.password_utils import hash_password
- import json
try:
body = await request.json()
except Exception:
diff --git a/app/routers/agent.py b/app/routers/agent.py
index 31d0e0f..148f5cb 100644
--- a/app/routers/agent.py
+++ b/app/routers/agent.py
@@ -8,22 +8,27 @@ import asyncio
import json
import logging
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
-from app.db import get_conn
-from app.config import settings
from app.auth.session import get_current_user
+from app.config import settings
+from app.db import get_conn
from app.services.agent_engine import AgentEngine, undo_action
-from app.services.llm_client import LLMClient, PROVIDERS, PROVIDER_MODELS
+from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
- get_llm_config, set_llm_config, provider_info,
- get_user_llm_key, list_user_llm_keys, upsert_user_llm_key,
- delete_user_llm_key, fetch_provider_models,
- mark_llm_config_verified, mark_user_llm_key_verified,
+ delete_user_llm_key,
+ fetch_provider_models,
+ get_llm_config,
+ get_user_llm_key,
+ list_user_llm_keys,
+ mark_llm_config_verified,
+ mark_user_llm_key_verified,
+ provider_info,
+ set_llm_config,
+ upsert_user_llm_key,
)
from app.services.tool_registry import ToolRegistry
-from app.services.permission_manager import PermissionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["agent"], prefix="/api/agent")
@@ -378,7 +383,7 @@ async def agent_writing(request: Request):
Returns plain Markdown (`text`) plus `model`/`offline` metadata. The
`properties` action is served by `/writing/properties`.
"""
- from app.services.ai_writing import AIWritingService, WRITING_ACTIONS
+ from app.services.ai_writing import WRITING_ACTIONS, AIWritingService
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
@@ -1020,4 +1025,4 @@ async def delete_agent(request: Request, agent_id: int):
raise HTTPException(status_code=404, detail="Agent introuvable")
conn.execute("DELETE FROM agents WHERE id=?", (agent_id,))
conn.commit()
- return {"id": agent_id, "status": "deleted"}
\ No newline at end of file
+ return {"id": agent_id, "status": "deleted"}
diff --git a/app/routers/api.py b/app/routers/api.py
index c5a028f..43b723e 100644
--- a/app/routers/api.py
+++ b/app/routers/api.py
@@ -4,16 +4,15 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
-from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
+from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
-from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
+from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
from app.services.gitea_client import gitea
-from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
diff --git a/app/routers/auth.py b/app/routers/auth.py
index 3455bc6..345c524 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
import secrets
-from fastapi import APIRouter, Request, Query
-from fastapi.responses import RedirectResponse, HTMLResponse
+from fastapi import APIRouter, Query, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
@@ -173,9 +173,9 @@ async def login(request: Request, provider: str = Query("gitea")):
@router.post("/register")
async def register(request: Request):
"""Register a new local account."""
+
from app.db import get_conn
from app.password_utils import hash_password
- import json
try:
body = await request.json()
except Exception:
@@ -210,7 +210,7 @@ async def register(request: Request):
user_data = dict(user)
# Log login
_log_login(user_data["id"], request)
- session = SessionManager.create_session(user_data)
+ session = SessionManager.create_session(user_data, request)
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -220,10 +220,12 @@ async def register(request: Request):
@router.post("/local-login")
async def local_login(request: Request):
"""Login with email + password."""
- from app.db import get_conn
- from app.password_utils import verify_password, is_locked
+ import time
+
from fastapi.responses import JSONResponse
- import json, time
+
+ from app.db import get_conn
+ from app.password_utils import is_locked, verify_password
try:
body = await request.json()
except Exception:
@@ -265,7 +267,7 @@ async def local_login(request: Request):
(str(time.time()), ud["id"]),
)
conn.commit()
- session = SessionManager.create_session(ud)
+ session = SessionManager.create_session(ud, request)
_log_login(ud["id"], request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -363,7 +365,7 @@ async def callback(
user_data = dict(user) if user else oauth_user
# Create session
- session = SessionManager.create_session(user_data)
+ session = SessionManager.create_session(user_data, request)
_log_login(user_data["id"], request)
response = RedirectResponse(url="/workspaces", status_code=302)
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
diff --git a/app/routers/automations.py b/app/routers/automations.py
index dbb9be3..a3597cf 100644
--- a/app/routers/automations.py
+++ b/app/routers/automations.py
@@ -4,11 +4,11 @@ from __future__ import annotations
import json
import logging
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
-from app.db import get_conn
from app.auth.session import SessionManager
-from app.services.automations import run_automation, get_page_context
+from app.db import get_conn
+from app.services.automations import get_page_context, run_automation
logger = logging.getLogger(__name__)
router = APIRouter(tags=["automations"])
@@ -171,4 +171,4 @@ async def automation_runs_history(request: Request, auto_id: int, limit: int = 5
ORDER BY created_at DESC, id DESC LIMIT ?""",
(auto_id, limit),
).fetchall()
- return {"runs": [dict(r) for r in rows]}
\ No newline at end of file
+ return {"runs": [dict(r) for r in rows]}
diff --git a/app/routers/board.py b/app/routers/board.py
index c761313..d3db1da 100644
--- a/app/routers/board.py
+++ b/app/routers/board.py
@@ -4,15 +4,15 @@ from __future__ import annotations
import json
import logging
-from fastapi import APIRouter, Request, HTTPException, Query
+from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
-from app.db import get_conn
-from app.services.gitea_client import gitea
from app.auth.session import SessionManager
+from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
+from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@@ -246,6 +246,26 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
with get_conn() as conn:
+ own_ws = (
+ "SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
+ "UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
+ )
+ own_ws_names = (
+ "SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
+ "UNION SELECT w.name FROM workspaces w "
+ "JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
+ )
+ # Scope "shared by me"-style lists to pages in the user's own workspaces
+ # (or legacy pages whose workspace_id is NULL but identify the workspace by text).
+ scope_cond = (
+ f"(workspace_id IN ({own_ws}) "
+ f"OR (workspace_id IS NULL AND lower(workspace) IN "
+ f"(SELECT lower(name) FROM ({own_ws_names}))) "
+ f"OR (workspace_id IS NULL AND lower(workspace) = lower("
+ f"(SELECT login FROM users WHERE id=?))))"
+ )
+ scope_params = (user_id, user_id, user_id, user_id, user_id)
+
made_nominal = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
@@ -253,16 +273,22 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
(user_id,),
).fetchall()
made_link = conn.execute(
- "SELECT id, title, workspace, updated_at FROM pages "
- "WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL"
+ f"SELECT id, title, workspace, updated_at FROM pages "
+ f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
+ f"AND {scope_cond}",
+ scope_params,
).fetchall()
made_flag = conn.execute(
- "SELECT id, title, workspace, updated_at FROM pages "
- "WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL"
+ f"SELECT id, title, workspace, updated_at FROM pages "
+ f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
+ f"AND {scope_cond}",
+ scope_params,
).fetchall()
published_rows = conn.execute(
- "SELECT id, title, workspace, updated_at FROM pages "
- "WHERE published=1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20"
+ f"SELECT id, title, workspace, updated_at FROM pages "
+ f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
+ f"ORDER BY updated_at DESC LIMIT 20",
+ scope_params,
).fetchall()
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
@@ -639,7 +665,6 @@ async def remove_favorite(request: Request, page_id: int):
@router.post("/api/share/{page_id:int}")
async def update_share(request: Request, page_id: int):
"""Save share settings for a page."""
- import json
body = await request.json()
mode = body.get("mode", "private")
published = body.get("published", False)
@@ -956,12 +981,12 @@ async def move_page(request: Request, page_id: int):
new_ws_id = body.get("workspace_id")
new_parent_id = body.get("parent_id", 0)
new_order = body.get("new_order", 0)
-
+
with get_conn() as conn:
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
-
+
if new_ws_id:
# Move to a different workspace: get the workspace name
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
@@ -981,7 +1006,7 @@ async def move_page(request: Request, page_id: int):
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id),
)
-
+
conn.commit()
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
"parent_id": new_parent_id})
diff --git a/app/routers/collaboration.py b/app/routers/collaboration.py
index 0ebd909..681b2e4 100644
--- a/app/routers/collaboration.py
+++ b/app/routers/collaboration.py
@@ -8,10 +8,10 @@ from __future__ import annotations
import logging
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
-from app.db import get_conn
from app.auth.session import SessionManager
+from app.db import get_conn
from app.services import notifications as notif
logger = logging.getLogger(__name__)
@@ -181,4 +181,4 @@ async def delete_comment(request: Request, comment_id: int):
raise HTTPException(403, "Not allowed to delete this comment")
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
conn.commit()
- return {"id": comment_id, "status": "deleted"}
\ No newline at end of file
+ return {"id": comment_id, "status": "deleted"}
diff --git a/app/routers/collections.py b/app/routers/collections.py
index 26c9bd5..8d4082c 100644
--- a/app/routers/collections.py
+++ b/app/routers/collections.py
@@ -5,14 +5,13 @@ import json
import logging
import sqlite3
-from fastapi import APIRouter, Request, HTTPException, Query
+from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
-from app.auth.session import SessionManager
-from app.services.property_types import validate_property_rule
-from app.services.db_templates import materialize_properties
from app.services.automations import fire_event
+from app.services.db_templates import materialize_properties
+from app.services.property_types import validate_property_rule
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@@ -1224,7 +1223,8 @@ async def remove_page_dependency(request: Request, collection_id: int, page_id:
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
"""API: auto-shift dates based on blocking dependencies."""
- from datetime import date as dt_date, timedelta
+ from datetime import date as dt_date
+ from datetime import timedelta
try:
body = await request.json()
@@ -1388,7 +1388,8 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
- from datetime import date as dt_date, timedelta
+ from datetime import date as dt_date
+ from datetime import timedelta
today = dt_date.today()
# Determine month/year from config or current
year = config.get("year", today.year)
diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py
index 1fa0ca9..0e77477 100644
--- a/app/routers/dashboard.py
+++ b/app/routers/dashboard.py
@@ -3,12 +3,12 @@ from __future__ import annotations
import logging
-from fastapi import APIRouter, Request, Query
-from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
+from fastapi import APIRouter, Query, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
-from app.services.gitea_client import gitea, get_user_gitea_client
from app.auth.session import SessionManager
from app.db import get_conn
+from app.services.gitea_client import get_user_gitea_client, gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@@ -253,6 +253,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from jinja2 import Environment, FileSystemLoader
+
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo)
@@ -283,6 +284,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
"""
from jinja2 import Environment, FileSystemLoader
+
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo)
@@ -311,6 +313,7 @@ async def view_page_root(request: Request, page_id: int):
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader
+
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
@@ -428,7 +431,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
"""Comprehensive help & documentation page."""
- from jinja2 import Environment, FileSystemLoader, BaseLoader
+ from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
@@ -612,7 +615,6 @@ def _get_user_id(request: Request) -> int:
@router.put("/api/user/profile")
async def update_profile(request: Request):
- import json
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
@@ -624,7 +626,6 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
- import json
from app.password_utils import hash_password
body = await request.json()
password = body.get("password", "").strip()
@@ -710,7 +711,8 @@ async def dashboard(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
).fetchone()[0]
if ws_count == 0:
- return RedirectResponse("/workspaces", status_code=302)
+ # v5.2.0: first-launch → onboarding wizard
+ return RedirectResponse("/welcome", status_code=302)
except Exception:
pass
return RedirectResponse("/local-workspace", status_code=302)
@@ -760,6 +762,7 @@ async def workspace_page(request: Request):
async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
+
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
@@ -816,7 +819,7 @@ async def list_workspace_projects(request: Request):
"""List all projects: built-in + Gitea + GitHub.
Uses the user's own Gitea token if connected, not the global admin token."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
-
+
builtin = []
with get_conn() as conn:
rows = conn.execute(
@@ -850,7 +853,6 @@ async def list_workspace_projects(request: Request):
@router.post("/api/workspace/projects")
async def create_workspace_project(request: Request):
- import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -882,7 +884,6 @@ async def list_members(request: Request, ws_id: int):
@router.post("/api/workspace/{ws_id:int}/members")
async def invite_member(request: Request, ws_id: int):
"""Invite a user to a workspace by email."""
- import json
body = await request.json()
email = body.get("email", "").strip()
role = body.get("role", "editor")
@@ -906,7 +907,6 @@ async def invite_member(request: Request, ws_id: int):
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
"""Change a member's role."""
- import json
body = await request.json()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
@@ -1048,14 +1048,14 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
-
+
# Get workspace owner name for author display
ws_owner = conn.execute(
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
(ws_id,),
).fetchone()
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
-
+
# Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows]
tags_map = {}
@@ -1070,12 +1070,12 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
-
+
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id)
-
+
# Compute size
size = 0
if r["content_format"] == "file":
@@ -1087,7 +1087,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
-
+
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
@@ -1238,7 +1238,6 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""
- import json
body = await request.json()
name = body.get("name", "Untitled").strip()
item_type = body.get("type", "page")
@@ -1262,7 +1261,6 @@ async def create_local_workspace_item(request: Request):
@router.put("/api/local-workspace/items/{item_id:int}")
async def rename_local_workspace_item(request: Request, item_id: int):
"""Rename a file."""
- import json
body = await request.json()
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
@@ -1299,8 +1297,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
- from pathlib import Path
import mimetypes
+ from pathlib import Path
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
@@ -1318,7 +1316,6 @@ async def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
async def move_local_workspace_item(request: Request, item_id: int):
"""Move an item to a new parent (drag & drop)."""
- import json
body = await request.json()
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
@@ -1583,7 +1580,6 @@ async def list_workspaces(request: Request):
@router.post("/api/workspaces")
async def create_workspace(request: Request):
"""Create a new workspace."""
- import json
body = await request.json()
name = body.get("name", "New Workspace").strip()
if not name:
@@ -1616,7 +1612,6 @@ async def create_workspace(request: Request):
@router.put("/api/workspaces/{ws_id:int}")
async def rename_workspace(request: Request, ws_id: int):
"""Rename a workspace."""
- import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -1669,9 +1664,9 @@ async def app_settings_page(request: Request):
@router.post("/api/settings/avatar")
async def upload_avatar(request: Request):
"""Upload a user avatar image."""
- from fastapi import UploadFile, File
+ import os
+ import uuid
from pathlib import Path
- import uuid, os
form = await request.form()
file = form.get("file")
if not file:
@@ -1698,8 +1693,9 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
- from fastapi.responses import FileResponse
from pathlib import Path
+
+ from fastapi.responses import FileResponse
filepath = Path("/data/avatars") / filename
if not filepath.is_file():
return JSONResponse({"error": "Not found"}, status_code=404)
@@ -1735,7 +1731,6 @@ async def set_avatar_color(request: Request):
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
"""Create a tag for the current user."""
- import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
@@ -1756,7 +1751,6 @@ async def create_tag_global(request: Request):
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
"""Update a tag (name or color) — only if owned by user."""
- import json
body = await request.json()
uid = _get_user_id(request)
with get_conn() as conn:
@@ -1831,7 +1825,6 @@ async def get_item_tags(item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/tags")
async def add_item_tag(request: Request, item_id: int):
"""Add a tag to an item (creates tag if new, scoped to user)."""
- import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
tag_color = body.get("color", "#787774")
@@ -1884,7 +1877,6 @@ async def search_by_tags(request: Request, tags: str = ""):
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
- import json
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
if not tag_names:
return {"items": []}
@@ -1921,7 +1913,6 @@ async def search_by_tags(request: Request, tags: str = ""):
@router.put("/api/settings/account")
async def update_account(request: Request):
"""Update current user's profile: full_name, login, email, password."""
- import json
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1951,8 +1942,9 @@ async def update_account(request: Request):
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
user_data = dict(row)
- # Refresh session cookie with updated data
- new_session = SessionManager.create_session(user_data)
+ # Refresh session cookie with updated data (keeps the same session id)
+ cookie = request.cookies.get("flowdeck_session", "")
+ new_session = SessionManager.refresh_session(cookie, user_data, request)
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
return response
@@ -1968,20 +1960,21 @@ async def sidebar_workspace_tree(request: Request):
in the main content area to keep the sidebar in sync.
"""
from jinja2 import Environment, FileSystemLoader
+
from app.routers.board import _load_workspace_pages
-
+
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return HTMLResponse("")
-
+
ws_cookie = request.cookies.get("flowdeck_workspace", "")
if not ws_cookie:
return HTMLResponse('
')
-
+
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('')
-
+
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
@@ -1995,14 +1988,14 @@ async def sidebar_workspace_tree(request: Request):
''
)
-
+
pages = _load_workspace_pages(ws_cookie)
if not pages:
return HTMLResponse(
''
)
-
+
# Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.from_string(
@@ -2079,7 +2072,6 @@ def _sanitize_id(block_id: str) -> str:
def _render_blocks_public(blocks: list) -> str:
"""Render FlowDeck blocks as plain HTML for public pages."""
- import json as _json
html_parts = []
for b in blocks:
t = b.get("type", "paragraph")
@@ -2207,7 +2199,6 @@ async def api_page_content(page_id: int):
@router.put("/api/pages/{page_id:int}/rename")
async def api_rename_page(page_id: int, request: Request):
"""Inline rename a page title."""
- import json as _json
body = await request.json()
title = (body.get("title") or "").strip()
if not title:
diff --git a/app/routers/export.py b/app/routers/export.py
index c8ed005..71330f5 100644
--- a/app/routers/export.py
+++ b/app/routers/export.py
@@ -13,10 +13,10 @@ from fastapi.responses import Response
from app.db import get_conn
from app.services.export import (
+ build_static_site_bytes,
page_to_markdown,
page_to_pdf_bytes,
page_to_standalone_html,
- build_static_site_bytes,
)
logger = logging.getLogger(__name__)
diff --git a/app/routers/gitea.py b/app/routers/gitea.py
index 86c30a9..32b5e01 100644
--- a/app/routers/gitea.py
+++ b/app/routers/gitea.py
@@ -1,5 +1,5 @@
"""FlowDeck — Gitea integration API routes."""
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
@@ -92,7 +92,6 @@ async def get_file(request: Request, owner: str, repo: str, path: str):
@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()
@@ -140,7 +139,6 @@ async def upload_file(request: Request, owner: str, repo: str):
@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", "")
@@ -214,9 +212,9 @@ async def list_private_pages(owner: str, repo: str, request: Request):
@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)
@@ -255,9 +253,9 @@ async def get_private_page(owner: str, repo: str, page_id: int, request: Request
@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)
diff --git a/app/routers/library.py b/app/routers/library.py
index 59fd7da..1be596c 100644
--- a/app/routers/library.py
+++ b/app/routers/library.py
@@ -3,11 +3,10 @@ from __future__ import annotations
import logging
-from fastapi import APIRouter, Request, Query
-from fastapi.responses import JSONResponse
+from fastapi import APIRouter, Query, Request
-from app.db import get_conn
from app.auth.session import SessionManager
+from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["library"], prefix="/api/library")
@@ -174,10 +173,10 @@ async def library_favorites(
uid = _get_user_id(request)
query = (
- f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
- f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
- f"FROM favorites f JOIN pages p ON p.id = f.page_id "
- f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
+ "SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
+ "p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
+ "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]
if tree:
diff --git a/app/routers/my_tasks.py b/app/routers/my_tasks.py
index cf6a02a..c67ff3b 100644
--- a/app/routers/my_tasks.py
+++ b/app/routers/my_tasks.py
@@ -4,12 +4,12 @@ from __future__ import annotations
import json
import logging
-from fastapi import APIRouter, Request, HTTPException, Query
+from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader
-from app.db import get_conn
from app.auth.session import SessionManager
+from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
diff --git a/app/routers/notes.py b/app/routers/notes.py
index 33a0cdd..34517ee 100644
--- a/app/routers/notes.py
+++ b/app/routers/notes.py
@@ -22,6 +22,7 @@ async def get_notes(request: Request, owner: str, repo: str):
content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
+
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -45,6 +46,7 @@ async def save_notes(request: Request, owner: str, repo: str):
conn.commit()
from jinja2 import Environment, FileSystemLoader
+
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
diff --git a/app/routers/notifications.py b/app/routers/notifications.py
index 0a3a027..38be1c8 100644
--- a/app/routers/notifications.py
+++ b/app/routers/notifications.py
@@ -3,10 +3,10 @@ from __future__ import annotations
import logging
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
-from app.db import get_conn
from app.auth.session import SessionManager
+from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["notifications"], prefix="/api/notifications")
@@ -123,4 +123,4 @@ async def search_users(request: Request, q: str = ""):
"""SELECT id, login, full_name, avatar_url, avatar_color
FROM users ORDER BY login LIMIT 20"""
).fetchall()
- return {"users": [dict(r) for r in rows]}
\ No newline at end of file
+ return {"users": [dict(r) for r in rows]}
diff --git a/app/routers/onboarding.py b/app/routers/onboarding.py
new file mode 100644
index 0000000..aaefe6c
--- /dev/null
+++ b/app/routers/onboarding.py
@@ -0,0 +1,135 @@
+"""FlowDeck — v5.2.0 Onboarding: /welcome wizard + its API.
+
+First-launch experience: create workspace → connect a forge (optional) →
+create the first project (welcome page), then land in the app.
+"""
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+
+from app.auth.session import SessionManager
+from app.db import get_conn
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["onboarding"])
+
+WORKSPACE_COOKIE = "flowdeck_workspace"
+
+
+def _require_user(request: Request) -> dict:
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user or not user.get("id"):
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return user
+
+
+@router.get("/welcome", response_class=HTMLResponse)
+async def onboarding_page(request: Request):
+ """Onboarding wizard. Redirects logged-out users to login and users who
+ already have a workspace straight to the app."""
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user:
+ return RedirectResponse("/auth/login?provider=local", status_code=302)
+ with get_conn() as conn:
+ ws_count = conn.execute(
+ "SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user["id"],)
+ ).fetchone()[0]
+ if ws_count > 0:
+ return RedirectResponse("/workspaces", status_code=302)
+
+ from jinja2 import Environment, FileSystemLoader
+ env = Environment(loader=FileSystemLoader("app/templates"))
+ template = env.get_template("welcome.html")
+ return HTMLResponse(content=template.render(
+ user=user,
+ gitea_url_configured=_forge_configured("gitea"),
+ github_url_configured=_forge_configured("github"),
+ ))
+
+
+def _forge_configured(provider: str) -> bool:
+ try:
+ from app.auth.providers import get_provider
+ return get_provider(provider) is not None
+ except Exception:
+ return False
+
+
+# ═══════════ Onboarding API ═══════════
+
+
+@router.post("/api/onboarding/workspace")
+async def onboarding_create_workspace(request: Request):
+ """Step 1 — create the first local workspace."""
+ user = _require_user(request)
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ name = (body.get("name") or "").strip() or "My Workspace"
+
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, '{}')",
+ (name, user["id"]),
+ )
+ conn.execute(
+ "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
+ (cur.lastrowid, user["id"]),
+ )
+ conn.commit()
+ ws_id = cur.lastrowid
+
+ response = JSONResponse({"status": "ok", "id": ws_id, "name": name})
+ response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
+ return response
+
+
+@router.post("/api/onboarding/project")
+async def onboarding_create_project(request: Request):
+ """Step 3 — create the first project: a welcome page in the workspace."""
+ user = _require_user(request)
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
+ workspace_id = body.get("workspace_id")
+
+ with get_conn() as conn:
+ ws = None
+ if workspace_id:
+ ws = conn.execute(
+ "SELECT id FROM workspaces WHERE id=? AND owner_id=?",
+ (workspace_id, user["id"]),
+ ).fetchone()
+ if not ws:
+ ws = conn.execute(
+ "SELECT id FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
+ (user["id"],),
+ ).fetchone()
+ if not ws:
+ raise HTTPException(status_code=400, detail="Create a workspace first")
+
+ blocks = [
+ {"id": "1", "type": "heading_1", "content": title},
+ {"id": "2", "type": "paragraph",
+ "content": "Welcome to FlowDeck 🎉 — your workspace is ready."},
+ {"id": "3", "type": "paragraph",
+ "content": "Use the slash command « / » in any page to add blocks, databases, to-dos and more."},
+ {"id": "4", "type": "paragraph",
+ "content": "Connect Gitea or GitHub in Settings → Integrations to sync your repositories."},
+ ]
+ cur = conn.execute(
+ "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
+ "VALUES (?, ?, ?, ?, 'blocks', 'Private')",
+ (user.get("login", "local"), ws["id"], title, json.dumps(blocks)),
+ )
+ conn.commit()
+ page_id = cur.lastrowid
+
+ return {"status": "ok", "id": page_id, "title": title, "workspace_id": ws["id"]}
diff --git a/app/routers/projects.py b/app/routers/projects.py
new file mode 100644
index 0000000..bc55f32
--- /dev/null
+++ b/app/routers/projects.py
@@ -0,0 +1,67 @@
+"""FlowDeck — v5.2.0 Projects API: list, register, manual sync + backups admin."""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, HTTPException, Request
+
+from app.auth.session import SessionManager
+from app.services import projects as projects_svc
+from app.services.backup import backup_db, list_backups
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["projects"], prefix="/api/projects")
+backups_router = APIRouter(tags=["backups"])
+
+
+def _require_admin(request: Request) -> dict:
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user or not user.get("is_admin"):
+ raise HTTPException(status_code=403, detail="Admin only")
+ return user
+
+
+@router.get("")
+async def list_projects(request: Request):
+ """List all synced projects (optionally filtered by type)."""
+ proj_type = request.query_params.get("type") or None
+ return {"projects": projects_svc.list_projects(proj_type)}
+
+
+@router.post("")
+async def create_project(request: Request):
+ """Register a standalone (builtin) project."""
+ body = await request.json()
+ name = (body.get("name") or "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="name required")
+ project = projects_svc.create_builtin_project(name, body.get("owner", ""), body.get("description", ""))
+ return {"status": "ok", "project": project}
+
+
+@router.post("/sync")
+async def sync_projects(request: Request):
+ """Trigger an immediate forge sync for every connected account."""
+ _require_admin(request)
+ stats = await projects_svc.sync_all_projects()
+ return {"status": "ok", "stats": stats}
+
+
+# ═══════════ Backups (admin) ═══════════
+
+
+@backups_router.post("/api/settings/backups/run")
+async def run_backup_now(request: Request):
+ """Admin: create a database backup immediately."""
+ _require_admin(request)
+ filename = backup_db()
+ if not filename:
+ raise HTTPException(status_code=400, detail="Backups disabled or no database file")
+ return {"status": "ok", "filename": filename}
+
+
+@backups_router.get("/api/settings/backups")
+async def admin_list_backups(request: Request):
+ """Admin: list stored backups."""
+ _require_admin(request)
+ return {"backups": list_backups()}
diff --git a/app/routers/public_api.py b/app/routers/public_api.py
index 789155c..7893d84 100644
--- a/app/routers/public_api.py
+++ b/app/routers/public_api.py
@@ -1,12 +1,13 @@
-"""FlowDeck — Public API router (v2.1.0)."""
+"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens)."""
from __future__ import annotations
-import json
+import hashlib
import logging
from secrets import token_urlsafe
-from fastapi import APIRouter, Request, HTTPException, Header, Depends
+from fastapi import APIRouter, Depends, Header, HTTPException, Request
+from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
@@ -14,28 +15,63 @@ router = APIRouter(tags=["public-api"], prefix="/api/v1")
DEFAULT_TOKEN = "fd-public-key"
+def _hash_token(token: str) -> str:
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def _token_owner(token: str) -> dict | None:
+ """Resolve an api_tokens row by its sha256 hash (revoked → None)."""
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT id, user_id, name FROM api_tokens WHERE token_hash=? AND revoked=0",
+ (_hash_token(token),),
+ ).fetchone()
+ if not row:
+ return None
+ conn.execute(
+ "UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)
+ )
+ conn.commit()
+ return dict(row)
+
+
def verify_token(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
- raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
+ raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
token = authorization[7:] # strip "Bearer "
if token == DEFAULT_TOKEN:
return token
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
- if not row:
+ if row:
+ return token
+ owner = _token_owner(token)
+ if not owner:
raise HTTPException(403, "Invalid API token")
return token
@router.post("/token")
async def generate_token(request: Request):
- """Generate a public API access token."""
+ """Generate a public API access token.
+
+ When an authenticated session is present the token is bound to that user
+ (revocable from Settings → API tokens); otherwise a legacy shared token is
+ created for backward compatibility.
+ """
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
- conn.execute(
- "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
- (0, token),
- )
+ if user and user.get("id"):
+ conn.execute(
+ "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
+ (user["id"], "API token", _hash_token(token), token[:12]),
+ )
+ else:
+ conn.execute(
+ "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
+ (0, token),
+ )
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer "}
diff --git a/app/routers/realtime.py b/app/routers/realtime.py
index f8f6467..0f3d1d8 100644
--- a/app/routers/realtime.py
+++ b/app/routers/realtime.py
@@ -46,4 +46,4 @@ async def ws_page(websocket: WebSocket, page_id: int):
except Exception as e: # noqa: BLE001
logger.debug("ws closed: %s", e)
finally:
- await manager.disconnect(conn)
\ No newline at end of file
+ await manager.disconnect(conn)
diff --git a/app/routers/search.py b/app/routers/search.py
index 4215825..0d0e4c4 100644
--- a/app/routers/search.py
+++ b/app/routers/search.py
@@ -5,7 +5,7 @@ editor pages and databases scoped to the current user's accessible workspaces.
"""
from __future__ import annotations
-from fastapi import APIRouter, Request, Query
+from fastapi import APIRouter, Query, Request
from app.auth.session import SessionManager
from app.services.search import search as search_service
@@ -24,4 +24,4 @@ async def search(request: Request, q: str = Query(default="")):
"pages": data["pages"],
"collections": data["collections"],
"total": len(data["pages"]) + len(data["collections"]),
- }
\ No newline at end of file
+ }
diff --git a/app/routers/security.py b/app/routers/security.py
new file mode 100644
index 0000000..eae128a
--- /dev/null
+++ b/app/routers/security.py
@@ -0,0 +1,121 @@
+"""FlowDeck — v5.2.0 Security: per-user API tokens & active sessions.
+
+Backend for the Settings → API tokens / Sessions UI.
+"""
+from __future__ import annotations
+
+import hashlib
+import logging
+from secrets import token_urlsafe
+
+from fastapi import APIRouter, HTTPException, Request
+
+from app.auth.session import SessionManager
+from app.db import get_conn
+
+logger = logging.getLogger(__name__)
+router = APIRouter(tags=["security"], prefix="/api/settings")
+
+
+def _hash_token(token: str) -> str:
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def _current_user_id(request: Request) -> int:
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user or not user.get("id"):
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return user["id"]
+
+
+# ═══════════ API tokens ═══════════
+
+
+@router.get("/tokens")
+async def list_tokens(request: Request):
+ """List the current user's API tokens (prefix only, no secrets)."""
+ uid = _current_user_id(request)
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT id, name, token_prefix, last_used_at, revoked, created_at "
+ "FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
+ (uid,),
+ ).fetchall()
+ return {"tokens": [dict(r) for r in rows]}
+
+
+@router.post("/tokens")
+async def create_token(request: Request):
+ """Create an API token for the current user. The secret is returned once."""
+ uid = _current_user_id(request)
+ body = await request.json()
+ name = (body.get("name") or "").strip() or "API token"
+ token = f"fd_{token_urlsafe(24)}"
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
+ (uid, name[:80], _hash_token(token), token[:12]),
+ )
+ conn.commit()
+ tid = cur.lastrowid
+ return {"id": tid, "name": name, "token": token,
+ "note": "Copy this token now — it won't be shown again."}
+
+
+@router.delete("/tokens/{token_id:int}")
+async def revoke_token(token_id: int, request: Request):
+ """Revoke an API token (soft delete)."""
+ uid = _current_user_id(request)
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT id FROM api_tokens WHERE id=? AND user_id=?", (token_id, uid)
+ ).fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Token not found")
+ conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
+ conn.commit()
+ return {"status": "revoked"}
+
+
+# ═══════════ Active sessions ═══════════
+
+
+@router.get("/sessions")
+async def list_sessions(request: Request):
+ """List the current user's active sessions with their devices."""
+ uid = _current_user_id(request)
+ current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
+ sessions = SessionManager.list_sessions(uid)
+ now = __import__("datetime").datetime.now()
+ for s in sessions:
+ s["is_current"] = (s["id"] == current_sid)
+ # A session older than 7 days is implicitly expired (cookie max-age).
+ created = s.get("created_at") or ""
+ try:
+ from datetime import datetime
+ created_dt = datetime.fromisoformat(str(created).replace("Z", ""))
+ s["expired"] = (now - created_dt).days >= 7
+ except Exception:
+ s["expired"] = False
+ return {"sessions": sessions}
+
+
+@router.post("/sessions/{sid}/revoke")
+async def revoke_session(sid: str, request: Request):
+ """Revoke an active session. If it's the current one, the user is logged out."""
+ uid = _current_user_id(request)
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT id FROM user_sessions WHERE id=? AND user_id=?", (sid, uid)
+ ).fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Session not found")
+ SessionManager.revoke_session(sid)
+ # Also wipe the OAuth state cookie if the current session was revoked.
+ current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
+ if current_sid == sid:
+ try:
+ request.session.clear()
+ except Exception:
+ pass
+ return {"status": "revoked"}
diff --git a/app/routers/sharing.py b/app/routers/sharing.py
index 9cf3a04..8257c51 100644
--- a/app/routers/sharing.py
+++ b/app/routers/sharing.py
@@ -6,10 +6,10 @@ import re
import unicodedata
from datetime import datetime
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
-from app.db import get_conn
from app.auth.session import SessionManager
+from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
diff --git a/app/routers/sidebar_config.py b/app/routers/sidebar_config.py
index 8e06775..65fb473 100644
--- a/app/routers/sidebar_config.py
+++ b/app/routers/sidebar_config.py
@@ -6,8 +6,8 @@ import logging
from fastapi import APIRouter, HTTPException, Request
-from app.db import get_conn
from app.auth.session import SessionManager
+from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sidebar"], prefix="/api/sidebar")
diff --git a/app/routers/webhooks.py b/app/routers/webhooks.py
index 813819a..476a15f 100644
--- a/app/routers/webhooks.py
+++ b/app/routers/webhooks.py
@@ -1,12 +1,12 @@
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
from __future__ import annotations
-import json
-import hmac
import hashlib
+import hmac
+import json
import logging
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.db import get_conn
diff --git a/app/routers/workspace.py b/app/routers/workspace.py
index a9de9f4..42d19e8 100644
--- a/app/routers/workspace.py
+++ b/app/routers/workspace.py
@@ -7,11 +7,11 @@ import json
import logging
import sqlite3
-from fastapi import APIRouter, Request, HTTPException
+from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
-from app.db import get_conn
from app.auth.session import SessionManager
+from app.db import get_conn
from app.services.automations import fire_event
logger = logging.getLogger(__name__)
@@ -561,7 +561,7 @@ async def sprint_burndown(request: Request, collection_id: int, sid: int):
completed += p["velocity_points"]
break
- from datetime import date, timedelta
+ from datetime import date
today = date.today()
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py
index 1e54f69..739130f 100644
--- a/app/services/agent_engine.py
+++ b/app/services/agent_engine.py
@@ -15,13 +15,12 @@ import asyncio
import json
import logging
import re
-from datetime import datetime
from app.config import settings
from app.db import get_conn
+from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
-from app.services.context_builder import ContextBuilder
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
@@ -446,4 +445,4 @@ def undo_action(action_id: int) -> bool:
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
conn.commit()
- return True
\ No newline at end of file
+ return True
diff --git a/app/services/automations.py b/app/services/automations.py
index 400fbac..244e96c 100644
--- a/app/services/automations.py
+++ b/app/services/automations.py
@@ -150,8 +150,8 @@ def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
return True, value
replaced = value
for key in props:
- if f"[[" + str(key) + "]]" in replaced:
- replaced = replaced.replace(f"[[" + str(key) + "]]", str(props[key]))
+ if "[[" + str(key) + "]]" in replaced:
+ replaced = replaced.replace("[[" + str(key) + "]]", str(props[key]))
if "{{title}}" in replaced:
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
if "{{id}}" in replaced:
@@ -397,4 +397,4 @@ async def automation_scheduler():
logger.warning("Cron automation %s errored", auto["id"])
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
- await asyncio.sleep(60)
\ No newline at end of file
+ await asyncio.sleep(60)
diff --git a/app/services/backup.py b/app/services/backup.py
new file mode 100644
index 0000000..191a750
--- /dev/null
+++ b/app/services/backup.py
@@ -0,0 +1,111 @@
+"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
+from __future__ import annotations
+
+import logging
+import shutil
+import time
+from datetime import datetime
+from pathlib import Path
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+def _backup_dir() -> Path:
+ d = Path(settings.backup_dir)
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def _db_path() -> Path:
+ return settings.db_path
+
+
+def backup_db(now: datetime | None = None) -> str | None:
+ """Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
+
+ Returns the backup filename, or None when backup is disabled or the
+ database file does not exist.
+ """
+ if not settings.backup_enabled:
+ return None
+ db_path = _db_path()
+ if str(db_path) == ":memory:" or not Path(db_path).is_file():
+ return None
+
+ now = now or datetime.now()
+ # Checkpoint the WAL so the backup is consistent.
+ try:
+ import sqlite3
+ with sqlite3.connect(str(db_path)) as conn:
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
+ except Exception:
+ pass
+
+ dest_dir = _backup_dir()
+ filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
+ dest = dest_dir / filename
+ shutil.copy2(db_path, dest)
+
+ # Prune old backups, keeping ``backup_keep`` most recent files.
+ prune_old_backups()
+ logger.info("Backup created: %s", dest)
+ return filename
+
+
+def list_backups() -> list[dict]:
+ """List existing backup files (name, size bytes, mtime)."""
+ files = []
+ for p in _backup_dir().glob("flowdeck-*.db"):
+ stat = p.stat()
+ files.append({
+ "filename": p.name,
+ "size": stat.st_size,
+ "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
+ })
+ files.sort(key=lambda f: f["filename"], reverse=True)
+ return files
+
+
+def prune_old_backups(keep: int | None = None) -> int:
+ """Delete the oldest backup files beyond ``keep``. Returns count removed."""
+ keep = keep if keep is not None else settings.backup_keep
+ files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
+ removed = 0
+ for p in files[keep:]:
+ try:
+ p.unlink()
+ removed += 1
+ except OSError:
+ logger.warning("Could not prune backup %s", p)
+ return removed
+
+
+def last_backup_age_hours() -> float | None:
+ """Hours since the most recent backup (None if none exists)."""
+ files = list(_backup_dir().glob("flowdeck-*.db"))
+ if not files:
+ return None
+ newest = max(files, key=lambda p: p.stat().st_mtime)
+ age = time.time() - newest.stat().st_mtime
+ return age / 3600
+
+
+def backup_due() -> bool:
+ """True when a backup should run now (interval elapsed since last one)."""
+ age = last_backup_age_hours()
+ if age is None:
+ return True
+ return age >= settings.backup_interval_hours
+
+
+async def backup_scheduler():
+ """Background loop: run a backup once per interval (default daily)."""
+ while True:
+ try:
+ if backup_due():
+ backup_db()
+ except Exception as exc: # never let the loop die
+ logger.warning("backup_scheduler error: %s", exc)
+ await __import__("asyncio").sleep(3600) # re-check hourly
diff --git a/app/services/collection_adapter.py b/app/services/collection_adapter.py
index 1182f95..e1271c4 100644
--- a/app/services/collection_adapter.py
+++ b/app/services/collection_adapter.py
@@ -6,7 +6,6 @@ without breaking the existing board routes.
from __future__ import annotations
import json
-from typing import Optional
from app.db import get_conn
@@ -49,7 +48,7 @@ class GiteaBoardCompat:
}
@staticmethod
- def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
+ def from_card(card_row, gitea_issue: dict | None = None) -> dict:
"""Convertit une card legacy en pseudo collection_page."""
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
priority = card_row.get("priority", "Medium")
@@ -83,7 +82,7 @@ class GiteaBoardCompat:
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
@staticmethod
- def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
+ def get_board_as_collection(owner: str, repo: str) -> dict | None:
"""Récupère un board spécifique comme collection."""
with get_conn() as conn:
row = conn.execute(
@@ -95,7 +94,7 @@ class GiteaBoardCompat:
return GiteaBoardCompat.from_board(dict(row))
@staticmethod
- def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
+ def get_board_cards(owner: str, repo: str, gitea_issues: list[dict] | None = None) -> list[dict]:
"""Récupère les cartes d'un board comme collection_pages."""
with get_conn() as conn:
board = conn.execute(
@@ -120,7 +119,7 @@ class GiteaBoardCompat:
]
@staticmethod
- def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
+ def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> int | None:
"""Sync un board Gitea vers une vraie collection.
Crée ou met à jour une collection liée à Gitea et importe les pages.
diff --git a/app/services/context_builder.py b/app/services/context_builder.py
index a7bafc4..2b3efd1 100644
--- a/app/services/context_builder.py
+++ b/app/services/context_builder.py
@@ -227,4 +227,4 @@ class ContextBuilder:
def _files_context(self, files: list[dict]) -> str:
return "## Attached files\n" + "\n".join(
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
- )
\ No newline at end of file
+ )
diff --git a/app/services/db_templates.py b/app/services/db_templates.py
index 249f548..f941d55 100644
--- a/app/services/db_templates.py
+++ b/app/services/db_templates.py
@@ -198,4 +198,4 @@ def create_from_template(
"filters": [],
})),
)
- return collection_id
\ No newline at end of file
+ return collection_id
diff --git a/app/services/export.py b/app/services/export.py
index 8ff699c..66bdd31 100644
--- a/app/services/export.py
+++ b/app/services/export.py
@@ -25,7 +25,6 @@ from urllib.parse import quote
from app.db import get_conn
-
# ═══════════════ Helpers ═══════════════
def _text(v: str, *, escape: bool = True) -> str:
diff --git a/app/services/forge_adapter.py b/app/services/forge_adapter.py
new file mode 100644
index 0000000..71211a8
--- /dev/null
+++ b/app/services/forge_adapter.py
@@ -0,0 +1,72 @@
+"""FlowDeck — v5.2.0 Forge abstraction (Gitea / GitHub).
+
+``ForgeAdapter`` defines the minimal contract a forge client must expose for the
+``projects`` table sync and the issue/board integration. ``GiteaAdapter`` wraps
+the existing ``GiteaClient``; ``GitHubAdapter`` (in ``github_adapter.py``) is the
+GitHub implementation.
+"""
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+
+class ForgeAdapter(ABC):
+ """Common forge API surface used by FlowDeck (v5.2.0)."""
+
+ kind = "base"
+
+ @abstractmethod
+ async def validate_token(self) -> bool:
+ """True when the stored credentials still work."""
+
+ @abstractmethod
+ async def list_repos(self, page: int = 1) -> list[dict]:
+ """List repositories for the authenticated user."""
+
+ @abstractmethod
+ async def get_repo_info(self, owner: str, repo: str) -> dict:
+ """Repository metadata (default_branch, clone_url, language, …)."""
+
+
+def normalize_repo(repo: dict, proj_type: str) -> dict:
+ """Project a forge repo dict onto the ``projects`` table columns."""
+ full_name = repo.get("full_name", "") or repo.get("fullName", "")
+ owner, _, name = full_name.partition("/")
+ return {
+ "name": name or repo.get("name", ""),
+ "owner": owner or repo.get("owner", {}).get("login", "") if isinstance(repo.get("owner"), dict) else (owner or ""),
+ "proj_type": proj_type,
+ "forge_id": str(repo.get("id", "") or ""),
+ "clone_url": repo.get("clone_url", "") or repo.get("ssh_url", ""),
+ "default_branch": repo.get("default_branch", ""),
+ "language": repo.get("language", ""),
+ "description": (repo.get("description") or "") or "",
+ }
+
+
+class GiteaAdapter(ForgeAdapter):
+ """Adapt the existing GiteaClient to the ForgeAdapter contract."""
+
+ kind = "gitea"
+
+ def __init__(self, client) -> None: # client = GiteaClient instance
+ self._client = client
+
+ async def validate_token(self) -> bool:
+ try:
+ await self._client.get_user_repos(page=1, limit=1)
+ return True
+ except Exception:
+ return False
+
+ async def list_repos(self, page: int = 1) -> list[dict]:
+ return await self._client.get_user_repos(page=page, limit=30)
+
+ async def get_repo_info(self, owner: str, repo: str) -> dict:
+ async with __import__("httpx").AsyncClient(timeout=15) as client:
+ resp = await client.get(
+ f"{self._client._base}/repos/{owner}/{repo}",
+ headers=self._client._headers,
+ )
+ resp.raise_for_status()
+ return resp.json()
diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py
index 26eaf03..31ffb5a 100644
--- a/app/services/formula_engine.py
+++ b/app/services/formula_engine.py
@@ -1,8 +1,8 @@
"""FlowDeck — Formula Engine (v1.5.0)."""
from __future__ import annotations
-from datetime import datetime, date, timedelta
-from typing import Any, Callable, Optional
+from datetime import date, datetime, timedelta
+from typing import Any, Callable
class FormulaEngine:
@@ -214,7 +214,7 @@ class FormulaEngine:
return val.split("...")[0]
return val
- def _end(self, ctx: dict, s: Any) -> Optional[str]:
+ def _end(self, ctx: dict, s: Any) -> str | None:
"""Extract end date from a date range."""
val = str(s)
if "..." in val:
diff --git a/app/services/github_adapter.py b/app/services/github_adapter.py
index f660a7d..20fe335 100644
--- a/app/services/github_adapter.py
+++ b/app/services/github_adapter.py
@@ -1,4 +1,4 @@
-"""FlowDeck — GitHub API adapter with caching."""
+"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
from __future__ import annotations
import base64
@@ -8,26 +8,36 @@ from typing import Any
import httpx
+from app.services.forge_adapter import ForgeAdapter
+
logger = logging.getLogger(__name__)
DEFAULT_TTL = 30 # seconds
-class GitHubAdapter:
+class GitHubAdapter(ForgeAdapter):
"""Async GitHub API client (v3 REST) with simple TTL cache.
- Authenticated via OAuth2 Bearer token.
+ Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
+ interface so Gitea and GitHub repos can be synced identically.
"""
- def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
+ kind = "github"
+
+ def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
+ transport: httpx.BaseTransport | None = None) -> None:
self._base = "https://api.github.com"
self._headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}
+ self._transport = transport
self._cache: dict[str, tuple[datetime, Any]] = {}
self._ttl = timedelta(seconds=ttl)
+ def _client(self) -> httpx.AsyncClient:
+ return httpx.AsyncClient(timeout=15, transport=self._transport)
+
# ── cache helpers ──
def _cached(self, key: str) -> Any | None:
@@ -48,7 +58,7 @@ class GitHubAdapter:
if cached:
return cached
- async with httpx.AsyncClient(timeout=15) as client:
+ async with self._client() as client:
resp = await client.get(
f"{self._base}/user/repos",
headers=self._headers,
@@ -81,7 +91,7 @@ class GitHubAdapter:
if cached:
return cached
- async with httpx.AsyncClient(timeout=15) as client:
+ async with self._client() as client:
# Resolve default branch commit SHA if not provided
if sha is None:
repo_info = await client.get(
@@ -128,7 +138,7 @@ class GitHubAdapter:
if cached:
return cached
- async with httpx.AsyncClient(timeout=15) as client:
+ async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers,
@@ -146,11 +156,64 @@ class GitHubAdapter:
self._set_cache(cache_key, content)
return content
+ # ── repo info (ForgeAdapter) ──
+
+ async def get_repo_info(self, owner: str, repo: str) -> dict:
+ """Repository metadata: default_branch, clone_url, languages, …"""
+ cache_key = f"repo_info:{owner}:{repo}"
+ cached = self._cached(cache_key)
+ if cached:
+ return cached
+
+ async with self._client() as client:
+ resp = await client.get(
+ f"{self._base}/repos/{owner}/{repo}",
+ headers=self._headers,
+ )
+ resp.raise_for_status()
+ info = resp.json()
+ repo_info = {
+ "id": info.get("id"),
+ "name": info.get("name"),
+ "owner": (info.get("owner") or {}).get("login", owner),
+ "full_name": info.get("full_name"),
+ "clone_url": info.get("clone_url", ""),
+ "default_branch": info.get("default_branch", "main"),
+ "description": info.get("description") or "",
+ "language": info.get("language") or "",
+ "html_url": info.get("html_url", ""),
+ }
+ # Languages are a separate endpoint.
+ try:
+ lang_resp = await client.get(
+ f"{self._base}/repos/{owner}/{repo}/languages",
+ headers=self._headers,
+ )
+ if lang_resp.status_code == 200:
+ langs = lang_resp.json()
+ if langs:
+ repo_info["language"] = max(langs, key=langs.get)
+ except Exception:
+ pass
+
+ self._set_cache(cache_key, repo_info)
+ return repo_info
+
+ async def get_languages(self, owner: str, repo: str) -> dict:
+ """Bytes per language for a repo."""
+ async with self._client() as client:
+ resp = await client.get(
+ f"{self._base}/repos/{owner}/{repo}/languages",
+ headers=self._headers,
+ )
+ resp.raise_for_status()
+ return resp.json()
+
# ── token validation ──
async def validate_token(self) -> bool:
"""Check whether the access token is still valid."""
- async with httpx.AsyncClient(timeout=10) as client:
+ async with self._client() as client:
resp = await client.get(
f"{self._base}/user",
headers=self._headers,
diff --git a/app/services/llm_config.py b/app/services/llm_config.py
index 1f249c4..8d32e1a 100644
--- a/app/services/llm_config.py
+++ b/app/services/llm_config.py
@@ -12,10 +12,9 @@ from __future__ import annotations
import json
import time
-from typing import Optional
from app.config import settings
-from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
+from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
# Providers whose /v1/models lists far more entries than /v1/chat/completions
# actually serves. The fetched list is validated (name filter + live probe)
@@ -198,7 +197,7 @@ def _mask_key(row) -> dict:
}
-def get_user_llm_key(user_id: int, provider: str) -> Optional[dict]:
+def get_user_llm_key(user_id: int, provider: str) -> dict | None:
"""Return a stored key row (includes the raw api_key — server-side only)."""
from app.db import get_conn
@@ -225,7 +224,7 @@ def list_user_llm_keys(user_id: int) -> list[dict]:
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
api_base: str = "", default_model: str = "",
- models: Optional[list[str]] = None) -> dict:
+ models: list[str] | None = None) -> dict:
"""Upsert a user's provider key. Empty api_key keeps the existing one
(allows saving model/base without re-typing the key). Saving a *different*
key resets the `verified` flag so the provider must pass a test again."""
@@ -370,6 +369,7 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
are retried once before giving up, so slow-but-working models survive.
"""
import asyncio
+
import httpx
sem = asyncio.Semaphore(concurrency)
@@ -406,4 +406,4 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
return None
results = await asyncio.gather(*(probe(m) for m in candidates))
- return [m for m in results if isinstance(m, str)]
\ No newline at end of file
+ return [m for m in results if isinstance(m, str)]
diff --git a/app/services/mailer.py b/app/services/mailer.py
index 10f2311..beafeee 100644
--- a/app/services/mailer.py
+++ b/app/services/mailer.py
@@ -83,4 +83,4 @@ def notify_user(
prefs = notifications.get_user_prefs(user_id)
if not prefs.get(prefs_key, True):
return False
- return send_email(row["email"], subject, body_text, cta_url)
\ No newline at end of file
+ return send_email(row["email"], subject, body_text, cta_url)
diff --git a/app/services/notifications.py b/app/services/notifications.py
index 8ad34fc..89b0d22 100644
--- a/app/services/notifications.py
+++ b/app/services/notifications.py
@@ -144,4 +144,4 @@ def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
(json.dumps(prefs), user_id),
)
conn.commit()
- return prefs
\ No newline at end of file
+ return prefs
diff --git a/app/services/permission_manager.py b/app/services/permission_manager.py
index 45251d4..e6d7576 100644
--- a/app/services/permission_manager.py
+++ b/app/services/permission_manager.py
@@ -99,4 +99,4 @@ class PermissionManager:
)
# A viewer can always read; editor can read+write.
if role not in READ_ROLES:
- raise HTTPException(status_code=403, detail="User has no access to this workspace")
\ No newline at end of file
+ raise HTTPException(status_code=403, detail="User has no access to this workspace")
diff --git a/app/services/projects.py b/app/services/projects.py
new file mode 100644
index 0000000..30e2163
--- /dev/null
+++ b/app/services/projects.py
@@ -0,0 +1,135 @@
+"""FlowDeck — v5.2.0 Projects: normalized forge-agnostic project registry.
+
+The ``projects`` table stores one row per repository across forges (builtin /
+gitea / github). A background scheduler refreshes metadata (default branch,
+language) periodically so the UI always shows up-to-date info.
+"""
+from __future__ import annotations
+
+import logging
+
+from app.config import settings
+from app.db import get_conn
+from app.services.forge_adapter import GiteaAdapter, normalize_repo
+
+logger = logging.getLogger(__name__)
+
+
+def register_repo(repo: dict, proj_type: str) -> int | None:
+ """Upsert a forge repo into the projects table. Returns project id."""
+ data = normalize_repo(repo, proj_type)
+ if not data["name"]:
+ return None
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT id FROM projects WHERE proj_type=? AND owner=? AND name=?",
+ (proj_type, data["owner"], data["name"]),
+ ).fetchone()
+ if existing:
+ conn.execute(
+ """UPDATE projects SET forge_id=?, clone_url=?, default_branch=?,
+ language=?, description=?, last_synced_at=CURRENT_TIMESTAMP
+ WHERE id=?""",
+ (data["forge_id"], data["clone_url"], data["default_branch"],
+ data["language"], data["description"], existing["id"]),
+ )
+ conn.commit()
+ return existing["id"]
+ cur = conn.execute(
+ """INSERT INTO projects
+ (name, proj_type, owner, forge_id, clone_url, default_branch, language, description, last_synced_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
+ (data["name"], proj_type, data["owner"], data["forge_id"], data["clone_url"],
+ data["default_branch"], data["language"], data["description"]),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def list_projects(proj_type: str | None = None) -> list[dict]:
+ with get_conn() as conn:
+ if proj_type:
+ rows = conn.execute(
+ "SELECT * FROM projects WHERE proj_type=? ORDER BY name",
+ (proj_type,),
+ ).fetchall()
+ else:
+ rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, name").fetchall()
+ return [dict(r) for r in rows]
+
+
+def create_builtin_project(name: str, owner: str = "", description: str = "") -> dict:
+ """Register a standalone (non-forge) project."""
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT id FROM projects WHERE proj_type='builtin' AND owner=? AND name=?",
+ (owner, name),
+ ).fetchone()
+ if existing:
+ return {"id": existing["id"], "name": name}
+ cur = conn.execute(
+ "INSERT INTO projects (name, proj_type, owner, description) VALUES (?, 'builtin', ?, ?)",
+ (name, owner, description),
+ )
+ conn.commit()
+ return {"id": cur.lastrowid, "name": name}
+
+
+# ═══════════ Periodic sync ═══════════
+
+
+async def _sync_gitea(user_id: int, token: str) -> int:
+ from app.services.gitea_client import GiteaClient
+ gitea = GiteaClient(user_token=token)
+ adapter = GiteaAdapter(gitea)
+ repos = await adapter.list_repos(page=1)
+ count = 0
+ for repo in repos:
+ if register_repo(repo, "gitea"):
+ count += 1
+ return count
+
+
+async def _sync_github(user_id: int, token: str) -> int:
+ from app.services.github_adapter import GitHubAdapter
+ adapter = GitHubAdapter(token)
+ repos = await adapter.list_all_repos()
+ count = 0
+ for repo in repos:
+ if register_repo(repo, "github"):
+ count += 1
+ return count
+
+
+async def sync_all_projects() -> dict:
+ """Refresh the projects table from every connected forge token."""
+ stats = {"gitea": 0, "github": 0, "error": 0}
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT user_id, provider, access_token FROM user_oauth_tokens "
+ "WHERE provider IN ('gitea','github') AND access_token != ''"
+ ).fetchall()
+ tokens = [dict(r) for r in rows]
+
+ for t in tokens:
+ try:
+ if t["provider"] == "gitea":
+ stats["gitea"] += await _sync_gitea(t["user_id"], t["access_token"])
+ elif t["provider"] == "github":
+ stats["github"] += await _sync_github(t["user_id"], t["access_token"])
+ except Exception as exc:
+ stats["error"] += 1
+ logger.warning("project sync (%s user=%s) failed: %s", t["provider"], t["user_id"], exc)
+ logger.info("projects sync done: %s", stats)
+ return stats
+
+
+async def project_sync_scheduler():
+ """Background loop: refresh projects every interval (default hourly)."""
+ while True:
+ if settings.project_sync_enabled:
+ try:
+ await sync_all_projects()
+ except Exception as exc:
+ logger.warning("project_sync_scheduler error: %s", exc)
+ await __import__("asyncio").sleep(settings.project_sync_interval_hours * 3600)
diff --git a/app/services/property_types.py b/app/services/property_types.py
index b7d7b69..28611b1 100644
--- a/app/services/property_types.py
+++ b/app/services/property_types.py
@@ -2,8 +2,8 @@
from __future__ import annotations
import json
-from datetime import datetime, timezone
-from typing import Any, Optional
+from datetime import UTC, datetime
+from typing import Any
# ── Property type definitions ──
@@ -106,7 +106,7 @@ SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
-def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
+def validate_property_value(prop_type: str, value: Any, options: list | None = None) -> tuple[bool, str]:
"""Validate a property value against its type. Returns (ok, error_message)."""
if value is None:
return True, ""
@@ -173,9 +173,9 @@ def parse_validation(validation) -> dict:
def validate_property_rule(
prop_type: str,
value: Any,
- validation: Optional[dict] = None,
+ validation: dict | None = None,
*,
- existing_values: Optional[list] = None,
+ existing_values: list | None = None,
) -> tuple[bool, str]:
"""Validate a property value against type + validation rules.
@@ -227,10 +227,10 @@ def validate_property_rule(
return True, ""
-def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
+def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
- return datetime.now(timezone.utc).isoformat()
+ return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
if user:
return {"id": user.get("id"), "login": user.get("login")}
diff --git a/app/services/realtime_server.py b/app/services/realtime_server.py
index bf28f92..a8ed8be 100644
--- a/app/services/realtime_server.py
+++ b/app/services/realtime_server.py
@@ -80,7 +80,7 @@ class Room:
self.blocks: list[dict] = []
self.title = ""
self.version = 0
- self.conns: set["RTConn"] = set()
+ self.conns: set[RTConn] = set()
self.persist_task: asyncio.Task | None = None
self.dirty = False
@@ -291,4 +291,4 @@ class RealtimeManager:
return {"blocks": room.blocks, "title": room.title, "version": room.version}
-manager = RealtimeManager()
\ No newline at end of file
+manager = RealtimeManager()
diff --git a/app/services/rollup_engine.py b/app/services/rollup_engine.py
index a7ce0ae..8e9ce0b 100644
--- a/app/services/rollup_engine.py
+++ b/app/services/rollup_engine.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import statistics
-from typing import Any, Optional
+from typing import Any
from app.db import get_conn
@@ -105,12 +105,12 @@ class RollupEngine:
return ROLLUP_FUNCTIONS[rollup_function](values)
-def _safe_avg(values: list) -> Optional[float]:
+def _safe_avg(values: list) -> float | None:
nums = [float(v) for v in values if v is not None]
return sum(nums) / len(nums) if nums else None
-def _safe_stat(values: list, fn) -> Optional[float]:
+def _safe_stat(values: list, fn) -> float | None:
nums = [float(v) for v in values if v is not None]
return fn(nums) if nums else None
@@ -123,12 +123,12 @@ def _numeric(gen):
pass
-def _safe_range(values: list) -> Optional[float]:
+def _safe_range(values: list) -> float | None:
nums = list(_numeric(v for v in values if v is not None))
return max(nums) - min(nums) if len(nums) >= 2 else None
-def _percent_checked(values: list) -> Optional[float]:
+def _percent_checked(values: list) -> float | None:
"""Percentage of true values (for checkbox properties)."""
if not values:
return 0.0
diff --git a/app/services/search.py b/app/services/search.py
index 876ae8a..0161533 100644
--- a/app/services/search.py
+++ b/app/services/search.py
@@ -8,7 +8,6 @@ from __future__ import annotations
import logging
import re
-from typing import Optional
from app.db import get_conn
from app.migrations import fts5_available
@@ -24,7 +23,7 @@ def _fts_terms(query: str) -> list[str]:
return [t.replace('"', '""') for t in tokens if t]
-def _fts_match(query: str) -> Optional[str]:
+def _fts_match(query: str) -> str | None:
"""Build a MATCH expression, or None when the query is not FTS-safe."""
terms = _fts_terms(query)
if not terms:
@@ -78,7 +77,7 @@ def _workspace_name(conn, workspace_id) -> str:
return ""
-def _scope_where(user_id: Optional[int]) -> tuple[str, list]:
+def _scope_where(user_id: int | None) -> tuple[str, list]:
"""SQL filter restricting results to the user's accessible workspaces."""
if user_id is None:
return "1=1", []
@@ -92,7 +91,7 @@ def _scope_where(user_id: Optional[int]) -> tuple[str, list]:
# ── Search entry point ──────────────────────────────────────────────────────
-def search(query: str, user_id: Optional[int] = None, limit: int = 20) -> dict:
+def search(query: str, user_id: int | None = None, limit: int = 20) -> dict:
"""Return unified search results: ``{pages: [...], collections: [...]}``."""
q = (query or "").strip()
if not q:
@@ -104,7 +103,7 @@ def search(query: str, user_id: Optional[int] = None, limit: int = 20) -> dict:
return {"pages": pages, "collections": collections}
-def _search_pages(conn, query: str, user_id: Optional[int], limit: int) -> list:
+def _search_pages(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
@@ -142,7 +141,7 @@ def _search_pages(conn, query: str, user_id: Optional[int], limit: int) -> list:
return _page_rows_to_results(conn, rows)
-def _search_collections(conn, query: str, user_id: Optional[int], limit: int) -> list:
+def _search_collections(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
rows = conn.execute(
@@ -184,4 +183,4 @@ def _page_rows_to_results(conn, rows) -> list:
"excerpt": excerpt[:160],
"url": f"/pages/{r['id']}",
})
- return results
\ No newline at end of file
+ return results
diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py
index fb328d1..ee54923 100644
--- a/app/services/tool_registry.py
+++ b/app/services/tool_registry.py
@@ -732,8 +732,8 @@ class ReadGiteaIssues(Tool):
}
async def execute(self, args, *, user_id=None) -> ToolResult:
- from app.services.gitea_client import GiteaClient, get_user_gitea_client
from app.auth.session import SessionManager
+ from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
@@ -758,9 +758,9 @@ class SyncGitea(Tool):
}
async def execute(self, args, *, user_id=None) -> ToolResult:
+ from app.auth.session import SessionManager
from app.services.collection_adapter import GiteaBoardCompat
from app.services.gitea_client import GiteaClient
- from app.auth.session import SessionManager
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
@@ -787,8 +787,8 @@ class CreateGiteaIssue(Tool):
}
async def execute(self, args, *, user_id=None) -> ToolResult:
- from app.services.gitea_client import GiteaClient
from app.auth.session import SessionManager
+ from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
try:
@@ -841,4 +841,4 @@ class ToolRegistry:
impl = self.tools.get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
- return await impl.execute(args, user_id=user_id)
\ No newline at end of file
+ return await impl.execute(args, user_id=user_id)
diff --git a/app/services/webhook_outbound.py b/app/services/webhook_outbound.py
index d67e5de..bbbe941 100644
--- a/app/services/webhook_outbound.py
+++ b/app/services/webhook_outbound.py
@@ -1,7 +1,6 @@
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
from __future__ import annotations
-import json
import logging
import httpx
diff --git a/app/templates/base.html b/app/templates/base.html
index 3dfac3a..059cd07 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -6,6 +6,8 @@
FlowDeck — {% block title_prefix %}Home{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bienvenue 👋
+ Configurons votre espace de travail en 3 étapes. Vous pourrez tout changer plus tard.
+
+
+
+
+
+
+
+
+ Connectez vos dépôts
+ Optionnel — syncronisez Gitea ou GitHub. Vous pouvez le faire plus tard dans Préférences → Intégrations.
+
+
+
🔗
+
+
Gitea
+
+ Non configuré par l'administrateur
+ Disponible — Issues, Kanban, fichiers en sync
+
+
+
+
+
+
+
+
🐙
+
+
GitHub
+
+ Non configuré par l'administrateur
+ Disponible — dépôts privés & publics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+