diff --git a/.gitignore b/.gitignore
index 08cd31f..087ef9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,3 +73,6 @@ video/
# Server logs
server.log
*.log
+
+# Auth database
+data/
diff --git a/Dockerfile.rag b/Dockerfile.rag
index afca770..9a5f4af 100644
--- a/Dockerfile.rag
+++ b/Dockerfile.rag
@@ -6,4 +6,6 @@ RUN pip install --no-cache-dir --timeout 300 \
lightrag-hku>=1.4.0 \
openai>=1.0.0 \
python-dotenv>=1.0.0 \
- sentence-transformers>=3.0.0
+ sentence-transformers>=3.0.0 \
+ bcrypt>=4.0.0 \
+ "python-jose[cryptography]"
diff --git a/backend/auth/__init__.py b/backend/auth/__init__.py
new file mode 100644
index 0000000..f5ba767
--- /dev/null
+++ b/backend/auth/__init__.py
@@ -0,0 +1,6 @@
+"""Authentication and multi-tenant access control."""
+
+from backend.auth.deps import get_current_user, require_admin
+from backend.auth.models import UserContext
+
+__all__ = ["UserContext", "get_current_user", "require_admin"]
diff --git a/backend/auth/database.py b/backend/auth/database.py
new file mode 100644
index 0000000..431e1a9
--- /dev/null
+++ b/backend/auth/database.py
@@ -0,0 +1,407 @@
+"""SQLite persistence for organizations, users, projects."""
+
+import os
+import sqlite3
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+from backend.auth.security import hash_password
+
+DB_PATH = Path("data/transcriba.db")
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS organizations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ slug TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ org_id INTEGER NOT NULL,
+ username TEXT NOT NULL,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ UNIQUE(org_id, username),
+ FOREIGN KEY (org_id) REFERENCES organizations(id)
+);
+
+CREATE TABLE IF NOT EXISTS projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ org_id INTEGER NOT NULL,
+ slug TEXT NOT NULL,
+ name TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ UNIQUE(org_id, slug),
+ FOREIGN KEY (org_id) REFERENCES organizations(id)
+);
+
+CREATE TABLE IF NOT EXISTS user_projects (
+ user_id INTEGER NOT NULL,
+ project_id INTEGER NOT NULL,
+ PRIMARY KEY (user_id, project_id),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
+);
+"""
+
+
+def _utcnow() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def get_db_path(config: Optional[dict] = None) -> Path:
+ env_path = os.getenv("AUTH_DATABASE_PATH")
+ if env_path:
+ return Path(env_path)
+ if config:
+ custom = config.get("auth", {}).get("database_path")
+ if custom:
+ return Path(custom)
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ return DB_PATH
+
+
+@contextmanager
+def get_connection(config: Optional[dict] = None):
+ db_path = get_db_path(config)
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(db_path, check_same_thread=False)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON")
+ conn.execute("PRAGMA journal_mode = WAL")
+ try:
+ yield conn
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+
+def init_db(config: Optional[dict] = None) -> None:
+ with get_connection(config) as conn:
+ conn.executescript(_SCHEMA)
+ migrate_schema(config)
+
+
+def migrate_schema(config: Optional[dict] = None) -> None:
+ """Добавляет owner_user_id для личных проектов пользователей."""
+ with get_connection(config) as conn:
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(projects)").fetchall()}
+ if "owner_user_id" not in cols:
+ conn.execute(
+ "ALTER TABLE projects ADD COLUMN owner_user_id INTEGER REFERENCES users(id)"
+ )
+ print("[Auth] Migration: projects.owner_user_id added")
+
+
+def count_users(config: Optional[dict] = None) -> int:
+ with get_connection(config) as conn:
+ row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()
+ return int(row["c"])
+
+
+def get_org_by_slug(slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
+ with get_connection(config) as conn:
+ row = conn.execute(
+ "SELECT id, slug, name, created_at FROM organizations WHERE slug = ?",
+ (slug,),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def create_organization(slug: str, name: str, config: Optional[dict] = None) -> Dict[str, Any]:
+ with get_connection(config) as conn:
+ cur = conn.execute(
+ "INSERT INTO organizations (slug, name, created_at) VALUES (?, ?, ?)",
+ (slug, name, _utcnow()),
+ )
+ org_id = cur.lastrowid
+ row = conn.execute(
+ "SELECT id, slug, name, created_at FROM organizations WHERE id = ?",
+ (org_id,),
+ ).fetchone()
+ return dict(row)
+
+
+def create_user(
+ org_id: int,
+ username: str,
+ password: str,
+ role: str = "user",
+ config: Optional[dict] = None,
+) -> Dict[str, Any]:
+ with get_connection(config) as conn:
+ cur = conn.execute(
+ """
+ INSERT INTO users (org_id, username, password_hash, role, is_active, created_at)
+ VALUES (?, ?, ?, ?, 1, ?)
+ """,
+ (org_id, username, hash_password(password), role, _utcnow()),
+ )
+ user_id = cur.lastrowid
+ row = conn.execute(
+ "SELECT id, org_id, username, role, is_active, created_at FROM users WHERE id = ?",
+ (user_id,),
+ ).fetchone()
+ return dict(row)
+
+
+def get_user_by_username(org_id: int, username: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
+ with get_connection(config) as conn:
+ row = conn.execute(
+ """
+ SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active,
+ o.slug AS org_slug, o.name AS org_name
+ FROM users u
+ JOIN organizations o ON o.id = u.org_id
+ WHERE u.org_id = ? AND u.username = ?
+ """,
+ (org_id, username),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def get_user_by_id(user_id: int, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
+ with get_connection(config) as conn:
+ row = conn.execute(
+ """
+ SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active,
+ o.slug AS org_slug, o.name AS org_name
+ FROM users u
+ JOIN organizations o ON o.id = u.org_id
+ WHERE u.id = ?
+ """,
+ (user_id,),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def list_users(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
+ with get_connection(config) as conn:
+ rows = conn.execute(
+ """
+ SELECT id, org_id, username, role, is_active, created_at
+ FROM users WHERE org_id = ? ORDER BY username
+ """,
+ (org_id,),
+ ).fetchall()
+ users = [dict(r) for r in rows]
+ for user in users:
+ user["projects"] = get_user_project_slugs(user["id"], config=config, conn=conn)
+ return users
+
+
+def get_user_project_slugs(
+ user_id: int,
+ config: Optional[dict] = None,
+ conn: Optional[sqlite3.Connection] = None,
+) -> List[str]:
+ """Org-wide projects assigned to user by admin (not personal)."""
+ query = """
+ SELECT p.slug FROM projects p
+ JOIN user_projects up ON up.project_id = p.id
+ WHERE up.user_id = ? AND p.owner_user_id IS NULL
+ ORDER BY p.slug
+ """
+
+ def _fetch(connection: sqlite3.Connection) -> List[str]:
+ rows = connection.execute(query, (user_id,)).fetchall()
+ return [row["slug"] for row in rows]
+
+ if conn is not None:
+ return _fetch(conn)
+
+ with get_connection(config) as connection:
+ return _fetch(connection)
+
+
+def get_owned_project_slugs(
+ user_id: int,
+ config: Optional[dict] = None,
+) -> List[str]:
+ with get_connection(config) as conn:
+ rows = conn.execute(
+ """
+ SELECT slug FROM projects
+ WHERE owner_user_id = ?
+ ORDER BY slug
+ """,
+ (user_id,),
+ ).fetchall()
+ return [row["slug"] for row in rows]
+
+
+def _row_to_project(row: sqlite3.Row) -> Dict[str, Any]:
+ data = dict(row)
+ data["scope"] = "personal" if data.get("owner_user_id") else "org"
+ return data
+
+
+def create_project(
+ org_id: int,
+ slug: str,
+ name: str,
+ owner_user_id: Optional[int] = None,
+ config: Optional[dict] = None,
+) -> Dict[str, Any]:
+ slug = slug.strip().lower()
+ with get_connection(config) as conn:
+ cur = conn.execute(
+ """
+ INSERT INTO projects (org_id, slug, name, owner_user_id, created_at)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (org_id, slug, name, owner_user_id, _utcnow()),
+ )
+ project_id = cur.lastrowid
+ row = conn.execute(
+ """
+ SELECT id, org_id, slug, name, owner_user_id, created_at
+ FROM projects WHERE id = ?
+ """,
+ (project_id,),
+ ).fetchone()
+ return _row_to_project(row)
+
+
+def list_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
+ with get_connection(config) as conn:
+ rows = conn.execute(
+ """
+ SELECT id, org_id, slug, name, owner_user_id, created_at
+ FROM projects WHERE org_id = ? ORDER BY slug
+ """,
+ (org_id,),
+ ).fetchall()
+ return [_row_to_project(r) for r in rows]
+
+
+def list_org_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
+ """Только общие проекты организации (без личных)."""
+ with get_connection(config) as conn:
+ rows = conn.execute(
+ """
+ SELECT id, org_id, slug, name, owner_user_id, created_at
+ FROM projects WHERE org_id = ? AND owner_user_id IS NULL
+ ORDER BY slug
+ """,
+ (org_id,),
+ ).fetchall()
+ return [_row_to_project(r) for r in rows]
+
+
+def set_user_projects(user_id: int, project_ids: List[int], config: Optional[dict] = None) -> None:
+ with get_connection(config) as conn:
+ conn.execute("DELETE FROM user_projects WHERE user_id = ?", (user_id,))
+ for project_id in project_ids:
+ conn.execute(
+ "INSERT INTO user_projects (user_id, project_id) VALUES (?, ?)",
+ (user_id, project_id),
+ )
+
+
+def get_project_by_slug(org_id: int, slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
+ with get_connection(config) as conn:
+ row = conn.execute(
+ """
+ SELECT id, org_id, slug, name, owner_user_id, created_at
+ FROM projects WHERE org_id = ? AND slug = ?
+ """,
+ (org_id, slug.strip().lower()),
+ ).fetchone()
+ return _row_to_project(row) if row else None
+
+
+def delete_project(project_id: int, config: Optional[dict] = None) -> None:
+ with get_connection(config) as conn:
+ conn.execute("DELETE FROM user_projects WHERE project_id = ?", (project_id,))
+ conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
+
+
+def bootstrap_from_config(config: dict) -> None:
+ init_db(config)
+ if count_users(config) > 0:
+ return
+
+ auth_cfg = config.get("auth", {})
+ bootstrap = auth_cfg.get("bootstrap", {})
+ org_slug = bootstrap.get("org_slug", "merakom")
+ org_name = bootstrap.get("org_name", "МЕРАКОМ")
+ admin_username = bootstrap.get("admin_username", "admin")
+ admin_password = (
+ os.getenv("AUTH_ADMIN_PASSWORD")
+ or bootstrap.get("admin_password")
+ or auth_cfg.get("admin_password", "admin123")
+ )
+
+ org = get_org_by_slug(org_slug, config)
+ if not org:
+ org = create_organization(org_slug, org_name, config)
+
+ create_user(org["id"], admin_username, admin_password, role="admin", config=config)
+
+ default_projects = bootstrap.get("default_projects") or [
+ {"slug": "2026", "name": "2026"},
+ {"slug": "gp-merakom", "name": "ГП МЕРАКОМ"},
+ ]
+ for proj in default_projects:
+ slug = str(proj.get("slug", "")).strip().lower()
+ name = str(proj.get("name", slug))
+ if slug and not get_project_by_slug(org["id"], slug, config):
+ create_project(org["id"], slug, name, owner_user_id=None, config=config)
+
+ print(f"[Auth] Bootstrap: org={org_slug}, admin={admin_username}")
+
+
+def migrate_legacy_data(org_slug: str) -> None:
+ """Move flat processed/* into org-scoped layout (one-time)."""
+ from backend.paths import MEETINGS_DIRNAME, RAG_CACHE_DIRNAME, PROCESSED_ROOT, write_folder_project_meta
+ from src.rag.parser import parse_project_from_filename
+ import shutil
+
+ legacy_rag = PROCESSED_ROOT / "lightrag_caches"
+ legacy_rag_test = PROCESSED_ROOT / "lightrag_caches_test"
+ org_root = PROCESSED_ROOT / org_slug
+ target_meetings = org_root / MEETINGS_DIRNAME
+ target_rag = org_root / RAG_CACHE_DIRNAME
+ target_meetings.mkdir(parents=True, exist_ok=True)
+ target_rag.mkdir(parents=True, exist_ok=True)
+
+ skip = {org_slug, "lightrag_caches", "lightrag_caches_test", MEETINGS_DIRNAME, RAG_CACHE_DIRNAME}
+ if PROCESSED_ROOT.exists():
+ for item in PROCESSED_ROOT.iterdir():
+ if not item.is_dir() or item.name in skip:
+ continue
+ dest = target_meetings / item.name
+ if not dest.exists():
+ shutil.move(str(item), str(dest))
+ print(f"[Auth] Migrated meeting folder: {item.name} -> {dest}")
+
+ for folder in target_meetings.iterdir():
+ if not folder.is_dir():
+ continue
+ meta_path = folder / ".project.json"
+ if not meta_path.exists():
+ project_slug = parse_project_from_filename(folder.name)
+ write_folder_project_meta(folder, project_slug)
+ print(f"[Auth] Assigned project {project_slug} -> {folder.name}")
+
+ if legacy_rag.exists() and legacy_rag != target_rag:
+ for item in legacy_rag.iterdir():
+ dest = target_rag / item.name
+ if item.is_dir() and not dest.exists():
+ shutil.move(str(item), str(dest))
+ if not any(legacy_rag.iterdir()):
+ legacy_rag.rmdir()
+ print(f"[Auth] Migrated RAG cache -> {target_rag}")
+
+ if legacy_rag_test.exists():
+ shutil.rmtree(legacy_rag_test, ignore_errors=True)
diff --git a/backend/auth/deps.py b/backend/auth/deps.py
new file mode 100644
index 0000000..f700018
--- /dev/null
+++ b/backend/auth/deps.py
@@ -0,0 +1,70 @@
+"""FastAPI auth dependencies."""
+
+from typing import Optional
+
+from fastapi import Depends, HTTPException, Query, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from backend.auth.models import UserContext
+from backend.auth.security import safe_decode_token
+from backend.auth.service import get_user_context
+from src.config import load_config
+
+bearer_scheme = HTTPBearer(auto_error=False)
+
+
+def _resolve_token(
+ credentials: Optional[HTTPAuthorizationCredentials],
+ token: Optional[str],
+) -> Optional[str]:
+ if credentials and credentials.scheme.lower() == "bearer":
+ return credentials.credentials
+ if token:
+ return token
+ return None
+
+
+async def get_current_user(
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
+ token: Optional[str] = Query(None, alias="token"),
+) -> UserContext:
+ raw_token = _resolve_token(credentials, token)
+ if not raw_token:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Требуется авторизация",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ config = load_config()
+ payload = safe_decode_token(raw_token, config)
+ if not payload:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Недействительный или просроченный токен",
+ )
+
+ user_id = int(payload.get("sub", 0))
+ ctx = get_user_context(user_id)
+ if not ctx:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Пользователь не найден или деактивирован",
+ )
+ return ctx
+
+
+async def require_admin(user: UserContext = Depends(get_current_user)) -> UserContext:
+ if not user.is_admin:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Требуются права администратора")
+ return user
+
+
+def get_user_from_token(token: Optional[str]) -> Optional[UserContext]:
+ if not token:
+ return None
+ config = load_config()
+ payload = safe_decode_token(token, config)
+ if not payload:
+ return None
+ return get_user_context(int(payload.get("sub", 0)))
diff --git a/backend/auth/models.py b/backend/auth/models.py
new file mode 100644
index 0000000..154325b
--- /dev/null
+++ b/backend/auth/models.py
@@ -0,0 +1,53 @@
+"""Auth data models."""
+
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+ROLES = ("admin", "director", "user")
+
+
+@dataclass
+class UserContext:
+ user_id: int
+ username: str
+ role: str
+ org_id: int
+ org_slug: str
+ org_name: str
+ project_slugs: List[str] = field(default_factory=list) # назначенные org-проекты
+ owned_project_slugs: List[str] = field(default_factory=list) # личные проекты
+
+ @property
+ def accessible_project_slugs(self) -> set[str]:
+ if self.has_all_projects_access:
+ return set()
+ return set(self.project_slugs) | set(self.owned_project_slugs)
+
+ @property
+ def is_admin(self) -> bool:
+ return self.role == "admin"
+
+ @property
+ def is_director(self) -> bool:
+ return self.role == "director"
+
+ @property
+ def has_all_projects_access(self) -> bool:
+ """Admin and director see all org projects and global RAG search."""
+ return self.role in ("admin", "director")
+
+ def can_access_project(self, project_slug: Optional[str]) -> bool:
+ if not project_slug:
+ return True
+ if self.has_all_projects_access:
+ return True
+ return project_slug in self.accessible_project_slugs
+
+ def filter_projects(self, projects: List[str]) -> List[str]:
+ if self.has_all_projects_access:
+ return projects
+ allowed = self.accessible_project_slugs
+ return [p for p in projects if p in allowed]
+
+ def can_global_search(self) -> bool:
+ return self.has_all_projects_access
diff --git a/backend/auth/routes.py b/backend/auth/routes.py
new file mode 100644
index 0000000..aeca3f7
--- /dev/null
+++ b/backend/auth/routes.py
@@ -0,0 +1,146 @@
+"""Auth HTTP routes."""
+
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+
+from backend.auth.deps import get_current_user, require_admin
+from backend.auth.models import ROLES, UserContext
+from backend.auth import database as db
+from backend.auth.service import (
+ authenticate,
+ create_org_user,
+ create_personal_project,
+ delete_personal_project,
+ get_user_context,
+ list_accessible_projects,
+ update_user_projects,
+ user_to_dict,
+)
+from src.config import load_config
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
+
+
+class LoginRequest(BaseModel):
+ org_slug: str = Field(default="merakom")
+ username: str
+ password: str
+
+
+class CreateUserRequest(BaseModel):
+ username: str
+ password: str
+ role: str = "user"
+ projects: List[str] = Field(default_factory=list)
+
+
+class CreateProjectRequest(BaseModel):
+ slug: str
+ name: str
+
+
+class UpdateUserProjectsRequest(BaseModel):
+ projects: List[str] = Field(default_factory=list)
+
+
+@router.post("/login")
+async def login(payload: LoginRequest):
+ result = authenticate(payload.org_slug, payload.username, payload.password)
+ if not result:
+ raise HTTPException(status_code=401, detail="Неверная организация, логин или пароль")
+ return result
+
+
+@router.get("/me")
+async def me(user: UserContext = Depends(get_current_user)):
+ return user_to_dict(user)
+
+
+@router.get("/projects")
+async def my_projects(user: UserContext = Depends(get_current_user)):
+ return {"projects": list_accessible_projects(user)}
+
+
+@router.post("/projects")
+async def create_my_project(payload: CreateProjectRequest, user: UserContext = Depends(get_current_user)):
+ try:
+ project = create_personal_project(user, payload.slug, payload.name)
+ return {"project": project}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ except Exception as e:
+ if "UNIQUE" in str(e):
+ raise HTTPException(status_code=409, detail="Проект уже существует") from e
+ raise HTTPException(status_code=400, detail=str(e)) from e
+
+
+@router.delete("/projects/{slug}")
+async def delete_my_project(slug: str, user: UserContext = Depends(get_current_user)):
+ try:
+ delete_personal_project(user, slug)
+ return {"deleted": slug}
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e)) from e
+
+
+@admin_router.get("/users")
+async def admin_list_users(admin: UserContext = Depends(require_admin)):
+ config = load_config()
+ rows = db.list_users(admin.org_id, config)
+ users = []
+ for row in rows:
+ ctx = get_user_context(row["id"])
+ if ctx:
+ users.append(user_to_dict(ctx))
+ return {"users": users}
+
+
+@admin_router.post("/users")
+async def admin_create_user(payload: CreateUserRequest, admin: UserContext = Depends(require_admin)):
+ if payload.role not in ROLES:
+ raise HTTPException(status_code=400, detail=f"role must be one of: {', '.join(ROLES)}")
+ try:
+ user = create_org_user(admin, payload.username, payload.password, payload.role, payload.projects)
+ return {"user": user}
+ except Exception as e:
+ if "UNIQUE" in str(e):
+ raise HTTPException(status_code=409, detail="Пользователь уже существует") from e
+ raise HTTPException(status_code=400, detail=str(e)) from e
+
+
+@admin_router.put("/users/{user_id}/projects")
+async def admin_set_user_projects(
+ user_id: int,
+ payload: UpdateUserProjectsRequest,
+ admin: UserContext = Depends(require_admin),
+):
+ try:
+ user = update_user_projects(admin, user_id, payload.projects)
+ return {"user": user}
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e)) from e
+
+
+@admin_router.get("/projects")
+async def admin_list_projects(admin: UserContext = Depends(require_admin)):
+ config = load_config()
+ return {"projects": db.list_projects(admin.org_id, config)}
+
+
+@admin_router.post("/projects")
+async def admin_create_project(payload: CreateProjectRequest, admin: UserContext = Depends(require_admin)):
+ slug = payload.slug.strip().lower()
+ if not slug:
+ raise HTTPException(status_code=400, detail="slug обязателен")
+ try:
+ project = db.create_project(admin.org_id, slug, payload.name, owner_user_id=None, config=load_config())
+ return {"project": project}
+ except Exception as e:
+ if "UNIQUE" in str(e):
+ raise HTTPException(status_code=409, detail="Проект уже существует") from e
+ raise HTTPException(status_code=400, detail=str(e)) from e
diff --git a/backend/auth/security.py b/backend/auth/security.py
new file mode 100644
index 0000000..dd6f2dd
--- /dev/null
+++ b/backend/auth/security.py
@@ -0,0 +1,71 @@
+"""Password hashing and JWT tokens."""
+
+import os
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, Optional
+
+import bcrypt
+from jose import JWTError, jwt
+
+ALGORITHM = "HS256"
+
+
+def get_jwt_secret(config: Optional[dict] = None) -> str:
+ env_secret = os.getenv("JWT_SECRET")
+ if env_secret:
+ return env_secret
+ if config:
+ auth_cfg = config.get("auth", {})
+ secret = auth_cfg.get("jwt_secret")
+ if secret:
+ return secret
+ return "dev-insecure-change-me"
+
+
+def get_jwt_expire_hours(config: Optional[dict] = None) -> int:
+ if config:
+ return int(config.get("auth", {}).get("jwt_expire_hours", 168))
+ return 168
+
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ try:
+ return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
+ except ValueError:
+ return False
+
+
+def create_access_token(
+ user_id: int,
+ org_id: int,
+ role: str,
+ username: str,
+ org_slug: str,
+ config: Optional[dict] = None,
+) -> str:
+ expire_hours = get_jwt_expire_hours(config)
+ expire = datetime.now(timezone.utc) + timedelta(hours=expire_hours)
+ payload = {
+ "sub": str(user_id),
+ "org_id": org_id,
+ "org_slug": org_slug,
+ "role": role,
+ "username": username,
+ "exp": expire,
+ }
+ return jwt.encode(payload, get_jwt_secret(config), algorithm=ALGORITHM)
+
+
+def decode_access_token(token: str, config: Optional[dict] = None) -> Dict[str, Any]:
+ return jwt.decode(token, get_jwt_secret(config), algorithms=[ALGORITHM])
+
+
+def safe_decode_token(token: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
+ try:
+ return decode_access_token(token, config)
+ except JWTError:
+ return None
diff --git a/backend/auth/service.py b/backend/auth/service.py
new file mode 100644
index 0000000..90899a2
--- /dev/null
+++ b/backend/auth/service.py
@@ -0,0 +1,180 @@
+"""Auth business logic."""
+
+import re
+from typing import Any, Dict, List, Optional
+
+from backend.auth import database as db
+from backend.auth.models import UserContext
+from backend.auth.security import create_access_token, verify_password
+from src.config import load_config
+
+
+def normalize_project_slug(slug: str) -> str:
+ value = slug.strip().lower()
+ value = re.sub(r"[^\w\-]", "-", value)
+ value = re.sub(r"-+", "-", value).strip("-_")
+ if not value:
+ raise ValueError("Некорректный slug проекта")
+ return value
+
+
+def _build_user_context(row: Dict[str, Any], config: Optional[dict] = None) -> UserContext:
+ user_id = row["id"]
+ return UserContext(
+ user_id=user_id,
+ username=row["username"],
+ role=row["role"],
+ org_id=row["org_id"],
+ org_slug=row["org_slug"],
+ org_name=row["org_name"],
+ project_slugs=db.get_user_project_slugs(user_id, config=config),
+ owned_project_slugs=db.get_owned_project_slugs(user_id, config=config),
+ )
+
+
+def _enrich_project(project: Dict[str, Any], ctx: UserContext) -> Dict[str, Any]:
+ item = dict(project)
+ item["is_owner"] = project.get("owner_user_id") == ctx.user_id
+ item["scope"] = project.get("scope") or ("personal" if project.get("owner_user_id") else "org")
+ return item
+
+
+def authenticate(org_slug: str, username: str, password: str) -> Optional[Dict[str, Any]]:
+ config = load_config()
+ org = db.get_org_by_slug(org_slug.strip().lower(), config)
+ if not org:
+ return None
+
+ user = db.get_user_by_username(org["id"], username.strip(), config)
+ if not user or not user.get("is_active"):
+ return None
+ if not verify_password(password, user["password_hash"]):
+ return None
+
+ ctx = _build_user_context(user, config)
+ token = create_access_token(
+ user_id=ctx.user_id,
+ org_id=ctx.org_id,
+ role=ctx.role,
+ username=ctx.username,
+ org_slug=ctx.org_slug,
+ config=config,
+ )
+ return {
+ "access_token": token,
+ "token_type": "bearer",
+ "user": user_to_dict(ctx),
+ }
+
+
+def get_user_context(user_id: int) -> Optional[UserContext]:
+ config = load_config()
+ row = db.get_user_by_id(user_id, config)
+ if not row or not row.get("is_active"):
+ return None
+ return _build_user_context(row, config)
+
+
+def user_to_dict(ctx: UserContext) -> Dict[str, Any]:
+ return {
+ "id": ctx.user_id,
+ "username": ctx.username,
+ "role": ctx.role,
+ "org_id": ctx.org_id,
+ "org_slug": ctx.org_slug,
+ "org_name": ctx.org_name,
+ "shared_projects": ctx.project_slugs,
+ "owned_projects": ctx.owned_project_slugs,
+ "projects": list(ctx.accessible_project_slugs) if not ctx.has_all_projects_access else [],
+ "is_admin": ctx.is_admin,
+ "is_director": ctx.is_director,
+ "all_projects_access": ctx.has_all_projects_access,
+ }
+
+
+def list_accessible_projects(ctx: UserContext) -> List[Dict[str, Any]]:
+ config = load_config()
+ all_projects = db.list_projects(ctx.org_id, config)
+ if ctx.has_all_projects_access:
+ return [_enrich_project(p, ctx) for p in all_projects]
+
+ result = []
+ assigned = set(ctx.project_slugs)
+ owned = set(ctx.owned_project_slugs)
+ for project in all_projects:
+ slug = project["slug"]
+ if project.get("owner_user_id") == ctx.user_id or slug in owned:
+ result.append(_enrich_project(project, ctx))
+ elif not project.get("owner_user_id") and slug in assigned:
+ result.append(_enrich_project(project, ctx))
+ return sorted(result, key=lambda p: (p["scope"] != "personal", p["slug"]))
+
+
+def ensure_project_access(ctx: UserContext, project_slug: str) -> None:
+ slug = project_slug.strip().lower()
+ if not ctx.can_access_project(slug):
+ raise PermissionError(f"Нет доступа к проекту: {slug}")
+
+
+def create_personal_project(ctx: UserContext, slug: str, name: str) -> Dict[str, Any]:
+ config = load_config()
+ normalized = normalize_project_slug(slug)
+ display_name = name.strip() or normalized
+ if db.get_project_by_slug(ctx.org_id, normalized, config):
+ raise ValueError("Проект с таким slug уже существует")
+ project = db.create_project(
+ ctx.org_id,
+ normalized,
+ display_name,
+ owner_user_id=ctx.user_id,
+ config=config,
+ )
+ return _enrich_project(project, ctx)
+
+
+def delete_personal_project(ctx: UserContext, slug: str) -> None:
+ config = load_config()
+ normalized = normalize_project_slug(slug)
+ project = db.get_project_by_slug(ctx.org_id, normalized, config)
+ if not project:
+ raise ValueError("Проект не найден")
+ if ctx.is_admin:
+ db.delete_project(project["id"], config)
+ return
+ if project.get("owner_user_id") != ctx.user_id:
+ raise PermissionError("Можно удалять только свои личные проекты")
+ db.delete_project(project["id"], config)
+
+
+def create_org_user(
+ ctx: UserContext,
+ username: str,
+ password: str,
+ role: str,
+ project_slugs: List[str],
+) -> Dict[str, Any]:
+ config = load_config()
+ user = db.create_user(ctx.org_id, username, password, role=role, config=config)
+ if role == "user" and project_slugs:
+ projects = db.list_org_projects(ctx.org_id, config)
+ slug_to_id = {p["slug"]: p["id"] for p in projects}
+ project_ids = [slug_to_id[s] for s in project_slugs if s in slug_to_id]
+ db.set_user_projects(user["id"], project_ids, config)
+ user_row = db.get_user_by_id(user["id"], config)
+ return user_to_dict(_build_user_context(user_row, config))
+
+
+def update_user_projects(ctx: UserContext, user_id: int, project_slugs: List[str]) -> Dict[str, Any]:
+ config = load_config()
+ target = db.get_user_by_id(user_id, config)
+ if not target or target["org_id"] != ctx.org_id:
+ raise ValueError("Пользователь не найден")
+ if target["role"] in ("admin", "director"):
+ return user_to_dict(_build_user_context(target, config))
+
+ projects = db.list_org_projects(ctx.org_id, config)
+ slug_to_id = {p["slug"]: p["id"] for p in projects}
+ project_ids = [slug_to_id[s] for s in project_slugs if s in slug_to_id]
+ db.set_user_projects(user_id, project_ids, config)
+ updated = db.get_user_by_id(user_id, config)
+ return user_to_dict(_build_user_context(updated, config))
diff --git a/backend/main.py b/backend/main.py
index bd30eb7..477d67e 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,88 +1,91 @@
"""FastAPI backend для сервиса транскрибации."""
import json
-import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
-from typing import Any, Dict, List, Optional
+from typing import List, Optional
-from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
+from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse
+from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
+from backend.auth.deps import get_current_user, get_user_from_token, require_admin
+from backend.auth.models import UserContext
+from backend.auth.routes import admin_router, router as auth_router
+from backend.auth import database as auth_db
+from backend.auth.service import ensure_project_access, list_accessible_projects
+from backend.paths import org_meetings_dir, org_rag_index_dir, resolve_meeting_path
from backend.queue import (
- UPLOAD_DIR,
- PROCESSED_DIR,
- save_upload,
+ delete_folder,
get_all_tasks,
+ get_download_path,
+ get_processed_tree,
get_queue_info,
get_task_status,
- get_processed_tree,
read_file_content,
+ save_upload,
set_progress_callback,
start_workers,
stop_workers,
)
-# Добавляем корень проекта в путь, чтобы импортировать src.rag
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import load_config, resolve_opencode_credentials
-from src.rag.indexer import get_project_names
-from src.rag.parser import parse_project_from_filename
-from src.rag.query import rag_chat, retrieve_context
+from src.rag.formatter import format_global_document
+from src.rag.indexer import get_project_names, index_meeting
+from src.rag.query import rag_chat
+
+STATIC_DIR = Path(__file__).parent / "static"
+
-# WebSocket менеджер
class ConnectionManager:
def __init__(self):
- self.active_connections: List[WebSocket] = []
-
- async def connect(self, websocket: WebSocket):
+ self.active_connections: list[tuple[WebSocket, Optional[UserContext]]] = []
+
+ async def connect(self, websocket: WebSocket, user: Optional[UserContext] = None):
await websocket.accept()
- self.active_connections.append(websocket)
-
+ self.active_connections.append((websocket, user))
+
def disconnect(self, websocket: WebSocket):
- if websocket in self.active_connections:
- self.active_connections.remove(websocket)
-
+ self.active_connections = [(ws, u) for ws, u in self.active_connections if ws is not websocket]
+
+ def set_user(self, websocket: WebSocket, user: UserContext):
+ self.active_connections = [(ws, user if ws is websocket else u) for ws, u in self.active_connections]
+
async def broadcast(self, message: dict):
- for conn in self.active_connections:
+ for conn, _user in self.active_connections:
try:
await conn.send_json(message)
except Exception:
pass
-manager = ConnectionManager()
-# Устанавливаем callback для отправки прогресса через WebSocket
+manager = ConnectionManager()
set_progress_callback(manager.broadcast)
@asynccontextmanager
async def lifespan(app: FastAPI):
- """Управление жизненным циклом приложения."""
config = load_config()
+ auth_db.init_db(config)
+ auth_db.bootstrap_from_config(config)
+ org_slug = config.get("auth", {}).get("bootstrap", {}).get("org_slug", "merakom")
+ auth_db.migrate_legacy_data(org_slug)
+
queue_cfg = config.get("queue", {})
transcribe_workers = int(queue_cfg.get("transcribe_workers", 2))
postprocess_workers = int(queue_cfg.get("postprocess_workers", 1))
print("🚀 Запуск рабочих процессов...")
- start_workers(
- transcribe_workers=transcribe_workers,
- postprocess_workers=postprocess_workers,
- )
+ start_workers(transcribe_workers=transcribe_workers, postprocess_workers=postprocess_workers)
yield
print("🛑 Остановка рабочих процессов...")
stop_workers()
-app = FastAPI(
- title="Transcription Service",
- version="1.0.0",
- lifespan=lifespan,
-)
+app = FastAPI(title="Transcription Service", version="2.0.0", lifespan=lifespan)
-# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -91,84 +94,132 @@ app.add_middleware(
allow_headers=["*"],
)
+app.include_router(auth_router)
+app.include_router(admin_router)
+
+
+def _org_index_dir(user: UserContext) -> Path:
+ return org_rag_index_dir(user.org_slug)
+
+
+async def _list_rag_project_slugs(user: UserContext) -> List[str]:
+ projects = await get_project_names(_org_index_dir(user))
+ return user.filter_projects(projects)
+
+
+async def _rag_chat_for_user(user: UserContext, question: str, history: list, project_name: Optional[str], mode: str):
+ if project_name:
+ ensure_project_access(user, project_name)
+ elif not user.can_global_search():
+ raise HTTPException(status_code=403, detail="Глобальный поиск доступен только администратору")
+
+ config = load_config()
+ rag_cfg = config.get("rag", {})
+ api_key, base_url = resolve_opencode_credentials(config)
+ return await rag_chat(
+ question=question,
+ working_dir_base=_org_index_dir(user),
+ history=history,
+ api_key=api_key,
+ project_name=project_name,
+ base_url=base_url,
+ chat_model=rag_cfg.get("chat_model", "deepseek-v4-flash-free"),
+ mode=mode,
+ index_model=rag_cfg.get("index_model", "mimo-v2.5-free"),
+ )
-# === API Endpoints ===
@app.get("/", response_class=HTMLResponse)
async def root():
- """Главная страница."""
- index_path = Path(__file__).parent / "static" / "index.html"
+ index_path = STATIC_DIR / "index.html"
if index_path.exists():
return index_path.read_text(encoding="utf-8")
- return "
Transcription Service
Frontend not built
"
+ return "Transcription Service
"
+
+
+@app.get("/login", response_class=HTMLResponse)
+async def login_page():
+ login_path = STATIC_DIR / "login.html"
+ if login_path.exists():
+ return login_path.read_text(encoding="utf-8")
+ return "Login page missing
"
@app.post("/upload")
-async def upload_file(file: UploadFile = File(...)):
- """Загружает файл и добавляет в очередь обработки."""
+async def upload_file(
+ file: UploadFile = File(...),
+ project: str = Form(...),
+ user: UserContext = Depends(get_current_user),
+):
content = await file.read()
- task_id, _ = await save_upload(content, file.filename or "upload.bin")
+ try:
+ task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project)
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
return {
"task_id": task_id,
"file": file.filename,
+ "project": project,
"status": "queued",
"message": "Файл добавлен в очередь обработки",
- "queue": get_queue_info(),
+ "queue": get_queue_info(user),
}
@app.post("/upload-batch")
-async def upload_batch(files: List[UploadFile] = File(...)):
- """Загружает несколько файлов пакетно — все ставятся в очередь."""
+async def upload_batch(
+ files: List[UploadFile] = File(...),
+ project: str = Form(...),
+ user: UserContext = Depends(get_current_user),
+):
if not files:
return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []}
results = []
for file in files:
content = await file.read()
- task_id, _ = await save_upload(content, file.filename or "upload.bin")
- results.append({
- "task_id": task_id,
- "file": file.filename,
- "status": "queued",
- })
+ try:
+ task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project)
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
+ results.append({"task_id": task_id, "file": file.filename, "project": project, "status": "queued"})
return {
"uploaded": len(results),
"tasks": results,
- "queue": get_queue_info(),
+ "queue": get_queue_info(user),
"message": f"{len(results)} файл(ов) добавлено в очередь",
}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
- """WebSocket для получения прогресса обработки."""
- await manager.connect(websocket)
+ token = websocket.query_params.get("token")
+ user = get_user_from_token(token)
+ if not user:
+ await websocket.close(code=4401)
+ return
+
+ await manager.connect(websocket, user)
try:
while True:
- # Ждём сообщения от клиента (ping/keepalive)
data = await websocket.receive_text()
msg = json.loads(data)
-
+
if msg.get("action") == "get_tasks":
- tasks = get_all_tasks()
await websocket.send_json({
"type": "tasks_list",
- "tasks": tasks,
- "queue": get_queue_info(),
+ "tasks": get_all_tasks(user),
+ "queue": get_queue_info(user),
})
elif msg.get("action") == "get_tree":
- tree = get_processed_tree()
await websocket.send_json({
"type": "file_tree",
- "tree": tree,
+ "tree": get_processed_tree(user),
})
- elif msg.get("action") == "rag_query":
- await _handle_rag_query(websocket, msg)
- elif msg.get("action") == "rag_query_global":
- await _handle_rag_query(websocket, msg)
+ elif msg.get("action") in ("rag_query", "rag_query_global"):
+ await _handle_rag_query_ws(websocket, msg, user)
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception:
@@ -176,202 +227,126 @@ async def websocket_endpoint(websocket: WebSocket):
@app.get("/api/tasks")
-async def api_tasks():
- """Возвращает список всех задач."""
- return {"tasks": get_all_tasks(), "queue": get_queue_info()}
+async def api_tasks(user: UserContext = Depends(get_current_user)):
+ return {"tasks": get_all_tasks(user), "queue": get_queue_info(user)}
@app.get("/api/tasks/{task_id}")
-async def api_task(task_id: str):
- """Возвращает статус конкретной задачи."""
- status = get_task_status(task_id)
+async def api_task(task_id: str, user: UserContext = Depends(get_current_user)):
+ status = get_task_status(task_id, user)
if not status:
return {"error": "Task not found"}
return status
@app.get("/api/files")
-async def api_files():
- """Возвращает дерево обработанных файлов."""
- return {"tree": get_processed_tree()}
+async def api_files(user: UserContext = Depends(get_current_user)):
+ return {"tree": get_processed_tree(user)}
@app.get("/api/files/content")
-async def api_file_content(path: str):
- """Возвращает содержимое файла."""
+async def api_file_content(path: str, user: UserContext = Depends(get_current_user)):
try:
- content = read_file_content(path)
+ content = read_file_content(user, path)
return {"content": content, "path": path}
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e:
return {"error": str(e)}
@app.get("/api/files/download")
-async def api_download(path: str):
- """Скачивает файл."""
- file_path = PROCESSED_DIR / path
- if not file_path.exists():
- return {"error": "File not found"}
- return FileResponse(file_path, filename=file_path.name)
-
-
-import shutil
-
-@app.delete("/api/folders/{folder_name}")
-async def api_delete_folder(folder_name: str):
- """Удаляет папку с обработанными файлами."""
- folder_path = PROCESSED_DIR / folder_name
- if not folder_path.exists():
- return {"error": "Folder not found"}
+async def api_download(path: str, user: UserContext = Depends(get_current_user)):
try:
- shutil.rmtree(folder_path)
- return {"deleted": folder_name}
+ file_path = get_download_path(user, path)
+ return FileResponse(file_path, filename=file_path.name)
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e:
return {"error": str(e)}
-# === RAG / Chat API ===
+@app.delete("/api/folders/{folder_name:path}")
+async def api_delete_folder(folder_name: str, user: UserContext = Depends(get_current_user)):
+ try:
+ delete_folder(user, folder_name)
+ return {"deleted": folder_name}
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
+ except Exception as e:
+ return {"error": str(e)}
+
@app.get("/api/rag/projects")
-async def api_rag_projects():
- """Возвращает список проектов с RAG-индексами."""
+async def api_rag_projects(user: UserContext = Depends(get_current_user)):
try:
- config = load_config()
- rag_cfg = config.get("rag", {})
- index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
- projects = await get_project_names(index_dir)
- return {"projects": projects}
+ db_projects = {p["slug"] for p in list_accessible_projects(user)}
+ indexed = await _list_rag_project_slugs(user)
+ merged = sorted(db_projects | set(indexed))
+ if not user.is_admin:
+ merged = user.filter_projects(merged)
+ return {"projects": merged}
except Exception as e:
return {"error": str(e), "projects": []}
@app.post("/api/rag/query")
-async def api_rag_query(payload: dict):
- """Запрос к чат-боту по конкретному проекту."""
+async def api_rag_query(payload: dict, user: UserContext = Depends(get_current_user)):
try:
- config = load_config()
- rag_cfg = config.get("rag", {})
- index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
- api_key, base_url = resolve_opencode_credentials(config)
- chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
- index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
- mode = payload.get("mode", "hybrid")
-
- result = await rag_chat(
- question=payload.get("question", ""),
- working_dir_base=index_dir,
- history=payload.get("history", []),
- api_key=api_key,
- project_name=payload.get("project"),
- base_url=base_url,
- chat_model=chat_model,
- mode=mode,
- index_model=index_model,
+ result = await _rag_chat_for_user(
+ user,
+ payload.get("question", ""),
+ payload.get("history", []),
+ payload.get("project"),
+ payload.get("mode", "hybrid"),
)
- return {
- "answer": result["answer"],
- "context": result["context"],
- "project": result["project"],
- }
+ return {"answer": result["answer"], "context": result["context"], "project": result["project"]}
+ except HTTPException:
+ raise
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e:
return {"error": str(e)}
@app.post("/api/rag/query-global")
-async def api_rag_query_global(payload: dict):
- """Глобальный запрос ко всем проектам."""
+async def api_rag_query_global(payload: dict, user: UserContext = Depends(get_current_user)):
try:
- config = load_config()
- rag_cfg = config.get("rag", {})
- index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
- api_key, base_url = resolve_opencode_credentials(config)
- chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
- index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
- mode = payload.get("mode", "hybrid")
-
- result = await rag_chat(
- question=payload.get("question", ""),
- working_dir_base=index_dir,
- history=payload.get("history", []),
- api_key=api_key,
- project_name=None,
- base_url=base_url,
- chat_model=chat_model,
- mode=mode,
- index_model=index_model,
+ result = await _rag_chat_for_user(
+ user,
+ payload.get("question", ""),
+ payload.get("history", []),
+ None,
+ payload.get("mode", "hybrid"),
)
- return {
- "answer": result["answer"],
- "context": result["context"],
- "project": None,
- }
+ return {"answer": result["answer"], "context": result["context"], "project": None}
+ except HTTPException:
+ raise
except Exception as e:
return {"error": str(e)}
-@app.get("/api/rag/tasks")
-async def api_rag_tasks(project: Optional[str] = None):
- """Возвращает action items из RAG."""
+@app.post("/api/rag/index/{folder_name:path}")
+async def api_rag_index_folder(folder_name: str, user: UserContext = Depends(get_current_user)):
try:
- config = load_config()
- rag_cfg = config.get("rag", {})
- index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
- api_key, base_url = resolve_opencode_credentials(config)
- chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
- index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
-
- question = "Перечисли все action items, задачи и ответственных из протоколов."
- if project:
- question = f"Перечисли все action items, задачи и ответственных по проекту {project}."
-
- result = await rag_chat(
- question=question,
- working_dir_base=index_dir,
- history=[],
- api_key=api_key,
- project_name=project,
- base_url=base_url,
- chat_model=chat_model,
- mode="hybrid",
- index_model=index_model,
- )
- return {
- "tasks": result["answer"],
- "project": project,
- }
- except Exception as e:
- return {"error": str(e)}
-
-
-@app.post("/api/rag/index/{folder_name}")
-async def api_rag_index_folder(folder_name: str):
- """Принудительная переиндексация папки с обработанным совещанием."""
- try:
- folder_path = PROCESSED_DIR / folder_name
+ folder_path = resolve_meeting_path(user.org_slug, folder_name)
if not folder_path.exists():
return {"error": "Folder not found"}
- # Ищем .txt файл с протоколом
+ from backend.queue import _folder_project_slug
+ project = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug))
+ if not project:
+ return {"error": "Project metadata not found"}
+ ensure_project_access(user, project)
+
txt_files = list(folder_path.glob("*.txt"))
if not txt_files:
return {"error": "No .txt protocol found in folder"}
- txt_path = txt_files[0]
- doc_text = txt_path.read_text(encoding="utf-8")
-
- # Определяем проект из имени папки
- project = parse_project_from_filename(folder_name)
-
+ doc_text = txt_files[0].read_text(encoding="utf-8")
config = load_config()
rag_cfg = config.get("rag", {})
- index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
- index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
api_key, base_url = resolve_opencode_credentials(config)
-
- from src.rag.indexer import index_meeting
- from src.rag.formatter import format_global_document
-
- # Для переиндексации используем простую заглушку метаданных
metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"}
global_doc_text = format_global_document(doc_text, metadata)
@@ -379,54 +354,47 @@ async def api_rag_index_folder(folder_name: str):
doc_text=doc_text,
global_doc_text=global_doc_text,
project_name=project,
- working_dir_base=index_dir,
- model=index_model,
+ working_dir_base=_org_index_dir(user),
+ model=rag_cfg.get("index_model", "mimo-v2.5-free"),
api_key=api_key,
base_url=base_url,
)
-
return {"indexed": folder_name, "project": project}
+ except PermissionError as e:
+ raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e:
return {"error": str(e)}
-# === WebSocket Chat Actions ===
-
-async def _handle_rag_query(websocket: WebSocket, msg: dict):
- """Обрабатывает rag_query через WebSocket."""
+async def _handle_rag_query_ws(websocket: WebSocket, msg: dict, user: UserContext):
try:
- config = load_config()
- rag_cfg = config.get("rag", {})
- index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
- api_key, base_url = resolve_opencode_credentials(config)
- chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
- index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
- mode = msg.get("mode", "hybrid")
-
- result = await rag_chat(
- question=msg.get("question", ""),
- working_dir_base=index_dir,
- history=msg.get("history", []),
- api_key=api_key,
- project_name=msg.get("project"),
- base_url=base_url,
- chat_model=chat_model,
- mode=mode,
- index_model=index_model,
+ project = msg.get("project")
+ if msg.get("action") == "rag_query_global":
+ project = None
+ result = await _rag_chat_for_user(
+ user,
+ msg.get("question", ""),
+ msg.get("history", []),
+ project,
+ msg.get("mode", "hybrid"),
)
-
await websocket.send_json({
"type": "rag_response",
"answer": result["answer"],
"context": result["context"],
"project": result["project"],
})
+ except HTTPException as e:
+ await websocket.send_json({"type": "rag_error", "error": e.detail})
+ except PermissionError as e:
+ await websocket.send_json({"type": "rag_error", "error": str(e)})
except Exception as e:
- await websocket.send_json({
- "type": "rag_error",
- "error": str(e),
- })
+ await websocket.send_json({"type": "rag_error", "error": str(e)})
-# Статические файлы
-app.mount("/static", StaticFiles(directory="backend/static"), name="static")
+@app.get("/api/health")
+async def health():
+ return {"status": "ok"}
+
+
+app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
diff --git a/backend/paths.py b/backend/paths.py
new file mode 100644
index 0000000..3b3b04a
--- /dev/null
+++ b/backend/paths.py
@@ -0,0 +1,49 @@
+"""Org-scoped filesystem paths."""
+
+import json
+from datetime import datetime
+from pathlib import Path
+
+DATA_ROOT = Path("data")
+UPLOAD_ROOT = Path("uploads")
+PROCESSED_ROOT = Path("processed")
+RAG_CACHE_DIRNAME = "lightrag_caches"
+MEETINGS_DIRNAME = "meetings"
+
+
+def org_upload_dir(org_slug: str, user_id: int) -> Path:
+ path = UPLOAD_ROOT / org_slug / str(user_id)
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def org_meetings_dir(org_slug: str) -> Path:
+ path = PROCESSED_ROOT / org_slug / MEETINGS_DIRNAME
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def org_rag_index_dir(org_slug: str) -> Path:
+ path = PROCESSED_ROOT / org_slug / RAG_CACHE_DIRNAME
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def resolve_meeting_path(org_slug: str, rel_path: str) -> Path:
+ """Resolve relative path under org meetings dir; reject traversal."""
+ base = org_meetings_dir(org_slug).resolve()
+ full = (base / rel_path).resolve()
+ if not str(full).startswith(str(base)):
+ raise ValueError("Invalid path")
+ return full
+
+
+def write_folder_project_meta(folder_path: Path, project_slug: str) -> None:
+ meta = {
+ "project_slug": project_slug.strip().lower(),
+ "created_at": datetime.now().isoformat(),
+ }
+ (folder_path / ".project.json").write_text(
+ json.dumps(meta, ensure_ascii=False),
+ encoding="utf-8",
+ )
diff --git a/backend/queue.py b/backend/queue.py
index 3817890..9c970ca 100644
--- a/backend/queue.py
+++ b/backend/queue.py
@@ -1,4 +1,4 @@
-"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены."""
+"""Фоновая очередь: транскрибация и post-processing с org/project изоляцией."""
import asyncio
import json
@@ -11,6 +11,9 @@ from typing import Any, Callable, Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent.parent))
+from backend.auth.models import UserContext
+from backend.auth.service import ensure_project_access
+from backend.paths import org_meetings_dir, org_rag_index_dir, org_upload_dir, resolve_meeting_path, write_folder_project_meta
from src.audio_utils import prepare_audio_input
from src.config import load_config, resolve_opencode_credentials
from src.document import build_document
@@ -22,13 +25,7 @@ from src.rag.formatter import (
format_summary_markdown,
)
from src.rag.indexer import index_meeting
-from src.rag.parser import classify_meeting, generate_meeting_brief, parse_project_from_filename
-
-
-UPLOAD_DIR = Path("uploads")
-PROCESSED_DIR = Path("processed")
-UPLOAD_DIR.mkdir(exist_ok=True)
-PROCESSED_DIR.mkdir(exist_ok=True)
+from src.rag.parser import classify_meeting, generate_meeting_brief
tasks: Dict[str, Dict[str, Any]] = {}
_progress_callback: Optional[Callable] = None
@@ -42,6 +39,33 @@ def set_progress_callback(callback: Callable):
_progress_callback = callback
+def _task_visible(task: Dict[str, Any], user: UserContext) -> bool:
+ if task.get("org_slug") != user.org_slug:
+ return False
+ if user.has_all_projects_access:
+ return True
+ return task.get("user_id") == user.user_id
+
+
+def _filter_tasks_for_user(user: UserContext) -> List[Dict[str, Any]]:
+ return [t for t in tasks.values() if _task_visible(t, user)]
+
+
+def _filter_queue_info(user: UserContext) -> Dict[str, Any]:
+ visible = _filter_tasks_for_user(user)
+ queued = sum(1 for t in visible if t.get("status") == "queued")
+ processing = sum(1 for t in visible if t.get("status") == "processing")
+ postprocessing = sum(1 for t in visible if t.get("status") == "postprocessing")
+ return {
+ "queued": queued,
+ "processing": processing,
+ "postprocessing": postprocessing,
+ "pending_transcribe": _transcribe_queue.qsize(),
+ "pending_postprocess": _postprocess_queue.qsize(),
+ "pending_in_queue": _transcribe_queue.qsize(),
+ }
+
+
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
if _progress_callback:
try:
@@ -52,6 +76,7 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
"message": message,
"status": status,
"file": task_info.get("file", ""),
+ "project": task_info.get("project_slug", ""),
"queue_position": task_info.get("queue_position"),
"result": result,
"error": error,
@@ -64,7 +89,7 @@ def _cleanup_upload(file_path: Path):
if not file_path.exists():
return
parent = file_path.parent
- if parent != UPLOAD_DIR and parent.name.startswith("task_"):
+ if parent.name.startswith("task_"):
shutil.rmtree(parent, ignore_errors=True)
else:
file_path.unlink(missing_ok=True)
@@ -85,8 +110,12 @@ def _default_metadata(project: str, segments: List[Dict[str, Any]]) -> Dict[str,
async def process_transcription(file_path: Path, task_id: str):
- """Этап 1: WhisperX + docx/md. После — в очередь summary/RAG."""
- display_name = tasks.get(task_id, {}).get("file", file_path.name)
+ task = tasks.get(task_id, {})
+ display_name = task.get("file", file_path.name)
+ org_slug = task["org_slug"]
+ project_slug = task["project_slug"]
+ meetings_dir = org_meetings_dir(org_slug)
+
tasks[task_id].update({
"status": "processing",
"progress": 0,
@@ -113,8 +142,9 @@ async def process_transcription(file_path: Path, task_id: str):
stem = Path(display_name).stem
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
folder_name = f"{stem}_{timestamp}"
- output_dir = PROCESSED_DIR / folder_name
+ output_dir = meetings_dir / folder_name
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
+ await asyncio.to_thread(write_folder_project_meta, output_dir, project_slug)
docx_path = output_dir / f"{stem}.docx"
md_path = output_dir / f"{stem}.md"
@@ -130,22 +160,26 @@ async def process_transcription(file_path: Path, task_id: str):
await asyncio.to_thread(_cleanup_upload, file_path)
+ rel_dir = str(output_dir.relative_to(meetings_dir))
post_position = _postprocess_queue.qsize() + 1
+ result_data = {
+ "docx": str(docx_path),
+ "md": str(md_path),
+ "dir": str(output_dir),
+ "rel_dir": rel_dir,
+ "project": project_slug,
+ }
tasks[task_id].update({
"status": "postprocessing",
"progress": 70,
"message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
- "result": {
- "docx": str(docx_path),
- "md": str(md_path),
- "dir": str(output_dir),
- },
+ "result": result_data,
})
await _send_progress(
task_id, 70,
f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
"postprocessing",
- result=tasks[task_id]["result"],
+ result=result_data,
)
await _postprocess_queue.put({
@@ -154,10 +188,10 @@ async def process_transcription(file_path: Path, task_id: str):
"output_dir": str(output_dir),
"stem": stem,
"segments_path": str(segments_path),
- "docx_path": str(docx_path),
- "md_path": str(md_path),
+ "org_slug": org_slug,
+ "project_slug": project_slug,
})
- print(f"[Transcribe] Задача {task_id} передана в post-processing")
+ print(f"[Transcribe] {task_id} -> post-processing (project={project_slug})")
except Exception as e:
error_msg = str(e)
@@ -172,11 +206,12 @@ async def process_transcription(file_path: Path, task_id: str):
async def process_postprocessing(job: Dict[str, Any]):
- """Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию."""
task_id = job["task_id"]
display_name = job["display_name"]
stem = job["stem"]
output_dir = Path(job["output_dir"])
+ org_slug = job["org_slug"]
+ project = job["project_slug"]
summary_path = output_dir / f"{stem}_summary.md"
txt_path = output_dir / f"{stem}.txt"
@@ -188,13 +223,13 @@ async def process_postprocessing(job: Dict[str, Any]):
await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing")
result_data = dict(tasks[task_id].get("result") or {})
+ meetings_dir = org_meetings_dir(org_slug)
try:
segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8"))
config = load_config()
rag_cfg = config.get("rag", {})
api_key, base_url = resolve_opencode_credentials(config)
- project = parse_project_from_filename(display_name)
meeting_text = build_meeting_text_only(segments)
metadata = _default_metadata(project, segments)
@@ -229,11 +264,7 @@ async def process_postprocessing(job: Dict[str, Any]):
chunk_size=summary_chunk_size,
)
summary_md = format_summary_markdown(metadata, brief, display_name)
- await asyncio.to_thread(
- summary_path.write_text,
- summary_md,
- encoding="utf-8",
- )
+ await asyncio.to_thread(summary_path.write_text, summary_md, encoding="utf-8")
result_data["summary"] = str(summary_path)
doc_text = format_meeting_document(segments, metadata, display_name)
@@ -241,10 +272,11 @@ async def process_postprocessing(job: Dict[str, Any]):
result_data["txt"] = str(txt_path)
result_data["metadata"] = metadata
result_data["project"] = project
+ result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir))
if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True):
await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing")
- index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
+ index_dir = org_rag_index_dir(org_slug)
global_doc_text = format_global_document(doc_text, metadata)
await index_meeting(
doc_text=doc_text,
@@ -265,6 +297,7 @@ async def process_postprocessing(job: Dict[str, Any]):
doc_text = format_meeting_document(segments, metadata, display_name)
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
result_data["txt"] = str(txt_path)
+ result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir))
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
tasks[task_id].update({
@@ -291,7 +324,6 @@ async def _transcribe_worker_loop(worker_id: int):
while True:
try:
task_id, file_path = await _transcribe_queue.get()
- print(f"[Transcribe Worker {worker_id}] задача {task_id}")
await process_transcription(file_path, task_id)
_transcribe_queue.task_done()
except asyncio.CancelledError:
@@ -305,7 +337,6 @@ async def _postprocess_worker_loop(worker_id: int):
while True:
try:
job = await _postprocess_queue.get()
- print(f"[Postprocess Worker {worker_id}] задача {job.get('task_id')}")
await process_postprocessing(job)
_postprocess_queue.task_done()
except asyncio.CancelledError:
@@ -315,7 +346,6 @@ async def _postprocess_worker_loop(worker_id: int):
def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1):
- """Запускает пулы воркеров транскрибации и post-processing."""
global _workers
_workers.clear()
for i in range(transcribe_workers):
@@ -330,10 +360,17 @@ def stop_workers():
w.cancel()
-async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
+async def save_upload(
+ content: bytes,
+ filename: str,
+ user: UserContext,
+ project_slug: str,
+) -> tuple[str, Path]:
+ ensure_project_access(user, project_slug)
+ slug = project_slug.strip().lower()
safe_name = Path(filename).name or "upload.bin"
task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
- task_dir = UPLOAD_DIR / task_id
+ task_dir = org_upload_dir(user.org_slug, user.user_id) / task_id
task_dir.mkdir(parents=True, exist_ok=True)
file_path = task_dir / safe_name
@@ -346,6 +383,10 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
"progress": 0,
"message": f"В очереди транскрибации (№{queue_position})",
"file": safe_name,
+ "project_slug": slug,
+ "org_slug": user.org_slug,
+ "user_id": user.user_id,
+ "username": user.username,
"queue_position": queue_position,
"result": None,
"error": None,
@@ -357,7 +398,9 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
return task_id, file_path
-def get_queue_info() -> Dict[str, Any]:
+def get_queue_info(user: Optional[UserContext] = None) -> Dict[str, Any]:
+ if user:
+ return _filter_queue_info(user)
queued = sum(1 for t in tasks.values() if t.get("status") == "queued")
processing = sum(1 for t in tasks.values() if t.get("status") == "processing")
postprocessing = sum(1 for t in tasks.values() if t.get("status") == "postprocessing")
@@ -371,11 +414,18 @@ def get_queue_info() -> Dict[str, Any]:
}
-def get_task_status(task_id: str) -> Optional[Dict[str, Any]]:
- return tasks.get(task_id)
+def get_task_status(task_id: str, user: Optional[UserContext] = None) -> Optional[Dict[str, Any]]:
+ task = tasks.get(task_id)
+ if not task:
+ return None
+ if user and not _task_visible(task, user):
+ return None
+ return task
-def get_all_tasks() -> List[Dict[str, Any]]:
+def get_all_tasks(user: Optional[UserContext] = None) -> List[Dict[str, Any]]:
+ if user:
+ return _filter_tasks_for_user(user)
return list(tasks.values())
@@ -392,39 +442,84 @@ def _file_sort_key(file_info: Dict[str, Any]) -> tuple:
return (4, name)
-def get_processed_tree() -> List[Dict[str, Any]]:
+def _folder_project_slug(folder_name: str, meetings_dir: Path) -> Optional[str]:
+ meta_path = meetings_dir / folder_name / ".project.json"
+ if meta_path.exists():
+ try:
+ data = json.loads(meta_path.read_text(encoding="utf-8"))
+ return data.get("project_slug")
+ except Exception:
+ pass
+ return None
+
+
+def get_processed_tree(user: UserContext) -> List[Dict[str, Any]]:
tree = []
- if not PROCESSED_DIR.exists():
+ meetings_dir = org_meetings_dir(user.org_slug)
+ if not meetings_dir.exists():
return tree
- skip_names = {"lightrag_caches", "lightrag_caches_test"}
skip_suffixes = ("_segments.json",)
- for item in sorted(PROCESSED_DIR.iterdir()):
- if not item.is_dir() or item.name in skip_names:
+ for item in sorted(meetings_dir.iterdir()):
+ if not item.is_dir():
continue
+ project_slug = _folder_project_slug(item.name, meetings_dir)
+ if project_slug and not user.can_access_project(project_slug):
+ continue
+
files = []
for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})):
- if f.is_file() and not f.name.endswith(skip_suffixes):
+ if f.is_file() and not f.name.endswith(skip_suffixes) and f.name != ".project.json":
files.append({
"name": f.name,
- "path": str(f.relative_to(PROCESSED_DIR)),
+ "path": str(f.relative_to(meetings_dir)),
"size": f.stat().st_size,
"ext": f.suffix.lower(),
"kind": "summary" if "_summary" in f.name else "protocol",
})
tree.append({
"name": item.name,
- "path": str(item.relative_to(PROCESSED_DIR)),
+ "path": str(item.relative_to(meetings_dir)),
+ "project": project_slug,
"files": files,
"created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
})
return tree
-def read_file_content(rel_path: str) -> str:
- full_path = PROCESSED_DIR / rel_path
+def read_file_content(user: UserContext, rel_path: str) -> str:
+ full_path = resolve_meeting_path(user.org_slug, rel_path)
if not full_path.exists() or not full_path.is_file():
raise FileNotFoundError(f"Файл не найден: {rel_path}")
+
+ folder = full_path.parent.name
+ meetings_dir = org_meetings_dir(user.org_slug)
+ project_slug = _folder_project_slug(folder, meetings_dir)
+ if project_slug and not user.can_access_project(project_slug):
+ raise PermissionError("Нет доступа к этому файлу")
+
with open(full_path, "r", encoding="utf-8") as f:
return f.read()
+
+
+def get_download_path(user: UserContext, rel_path: str) -> Path:
+ full_path = resolve_meeting_path(user.org_slug, rel_path)
+ if not full_path.exists() or not full_path.is_file():
+ raise FileNotFoundError(f"Файл не найден: {rel_path}")
+ folder = full_path.parent.name
+ meetings_dir = org_meetings_dir(user.org_slug)
+ project_slug = _folder_project_slug(folder, meetings_dir)
+ if project_slug and not user.can_access_project(project_slug):
+ raise PermissionError("Нет доступа к этому файлу")
+ return full_path
+
+
+def delete_folder(user: UserContext, folder_rel: str) -> None:
+ folder_path = resolve_meeting_path(user.org_slug, folder_rel)
+ if not folder_path.is_dir():
+ raise FileNotFoundError("Folder not found")
+ project_slug = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug))
+ if project_slug and not user.can_access_project(project_slug):
+ raise PermissionError("Нет доступа к этой папке")
+ shutil.rmtree(folder_path)
diff --git a/backend/static/app.js b/backend/static/app.js
index 3a5fe1a..47553ec 100644
--- a/backend/static/app.js
+++ b/backend/static/app.js
@@ -10,21 +10,158 @@ class TranscriptionApp {
this.renderTimeout = null;
this.chatHistory = [];
this.chatProjects = [];
+ this.uploadProjects = [];
this.isChatThinking = false;
+ this.user = null;
this.init();
}
init() {
- this.connectWebSocket();
- this.setupUpload();
- this.setupChat();
- this.loadChatHistory();
+ if (!Auth.requireAuth()) return;
+ this.user = Auth.getUser();
+ this.setupUserBar();
+ this.loadProjects().then(() => {
+ this.connectWebSocket();
+ this.setupProjectsWorkspace();
+ this.setupUpload();
+ this.setupChat();
+ this.setupAdmin();
+ this.loadChatHistory();
+ });
+ }
+
+ setupUserBar() {
+ const info = document.getElementById('userInfo');
+ const logoutBtn = document.getElementById('logoutBtn');
+ if (info && this.user) {
+ const roleLabel = {
+ admin: 'администратор',
+ director: 'директор',
+ user: 'пользователь',
+ }[this.user.role] || this.user.role;
+ info.textContent = `${this.user.username} · ${this.user.org_name} · ${roleLabel}`;
+ }
+ logoutBtn?.addEventListener('click', () => Auth.logout());
+ }
+
+ downloadUrl(path) {
+ const token = encodeURIComponent(Auth.getToken() || '');
+ return `/api/files/download?path=${encodeURIComponent(path)}&token=${token}`;
+ }
+
+ async loadProjects() {
+ try {
+ const response = await Auth.apiFetch('/api/auth/projects');
+ const result = await response.json();
+ this.uploadProjects = result.projects || [];
+ this.renderMyProjectsList();
+ this.populateProjectSelect('uploadProjectSelect', this.uploadProjects, false);
+ await this.loadChatProjects();
+ } catch (e) {
+ console.error('Failed to load projects:', e);
+ this.showToast('Не удалось загрузить проекты', 'error');
+ }
+ }
+
+ populateProjectSelect(selectId, projects, includeGlobal) {
+ const select = document.getElementById(selectId);
+ if (!select) return;
+ select.innerHTML = '';
+ if (includeGlobal && this.user?.all_projects_access) {
+ const allOpt = document.createElement('option');
+ allOpt.value = '';
+ allOpt.textContent = 'Все проекты';
+ select.appendChild(allOpt);
+ }
+ projects.forEach(p => {
+ const opt = document.createElement('option');
+ opt.value = p.slug || p;
+ const scope = p.scope === 'personal' ? ' · мой' : (p.scope === 'org' ? ' · общий' : '');
+ opt.textContent = `${p.name || p.slug || p}${scope}`;
+ select.appendChild(opt);
+ });
+ if (!select.options.length) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = 'Нет доступных проектов';
+ select.appendChild(opt);
+ }
+ }
+
+ renderMyProjectsList() {
+ const container = document.getElementById('myProjectsList');
+ if (!container) return;
+
+ const projects = this.uploadProjects || [];
+ if (!projects.length) {
+ container.innerHTML = 'Нет проектов. Создайте первый проект ниже.
';
+ return;
+ }
+
+ container.innerHTML = projects.map(p => {
+ const badge = p.scope === 'personal'
+ ? 'мой'
+ : 'общий';
+ const deleteBtn = p.is_owner
+ ? ``
+ : '';
+ return `
+
+
+ ${this.escapeHtml(p.name || p.slug)}
+ ${badge}
+
+
${this.escapeHtml(p.slug)}
+ ${deleteBtn}
+
+ `;
+ }).join('');
+
+ container.querySelectorAll('.project-delete').forEach(btn => {
+ btn.addEventListener('click', () => this.deleteMyProject(btn.dataset.slug));
+ });
+ }
+
+ setupProjectsWorkspace() {
+ document.getElementById('createMyProjectForm')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const slug = document.getElementById('myProjectSlug').value.trim();
+ const name = document.getElementById('myProjectName').value.trim();
+ try {
+ const response = await Auth.apiFetch('/api/auth/projects', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ slug, name }),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || 'Ошибка создания проекта');
+ this.showToast(`Проект «${data.project.name}» создан`, 'success');
+ e.target.reset();
+ await this.loadProjects();
+ } catch (err) {
+ this.showToast(err.message, 'error');
+ }
+ });
+ }
+
+ async deleteMyProject(slug) {
+ if (!confirm(`Удалить проект «${slug}»?\n\nИндекс RAG и файлы не удаляются автоматически.`)) return;
+ try {
+ const response = await Auth.apiFetch(`/api/auth/projects/${encodeURIComponent(slug)}`, {
+ method: 'DELETE',
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || 'Ошибка удаления');
+ this.showToast(`Проект «${slug}» удалён`, 'success');
+ await this.loadProjects();
+ } catch (err) {
+ this.showToast(err.message, 'error');
+ }
}
// ===== WebSocket =====
connectWebSocket() {
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
+ this.ws = new WebSocket(Auth.wsUrl('/ws'));
this.ws.onopen = () => {
console.log('WebSocket connected');
@@ -38,7 +175,12 @@ class TranscriptionApp {
this.handleWebSocketMessage(data);
};
- this.ws.onclose = () => {
+ this.ws.onclose = (event) => {
+ if (event.code === 4401) {
+ Auth.clearSession();
+ window.location.href = '/login';
+ return;
+ }
console.log('WebSocket disconnected, reconnecting in 3s...');
setTimeout(() => this.connectWebSocket(), 3000);
};
@@ -126,7 +268,14 @@ class TranscriptionApp {
async handleFiles(files) {
if (!files.length) return;
+ const project = document.getElementById('uploadProjectSelect')?.value;
+ if (!project) {
+ this.showToast('Выберите проект перед загрузкой', 'error');
+ return;
+ }
+
const formData = new FormData();
+ formData.append('project', project);
for (const file of files) {
formData.append('files', file);
}
@@ -134,7 +283,7 @@ class TranscriptionApp {
try {
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
- const response = await fetch('/upload-batch', {
+ const response = await Auth.apiFetch('/upload-batch', {
method: 'POST',
body: formData,
});
@@ -273,7 +422,7 @@ class TranscriptionApp {
const isDocx = file.ext === '.docx';
const isTxt = file.ext === '.txt';
const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎';
- const downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`;
+ const downloadUrl = this.downloadUrl(file.path);
const cssClass = isSummary ? 'file-item file-summary' : 'file-item';
if (isDocx) {
@@ -363,7 +512,7 @@ class TranscriptionApp {
async deleteFolder(folderName, folderElement) {
try {
- const response = await fetch(`/api/folders/${encodeURIComponent(folderName)}`, {
+ const response = await Auth.apiFetch(`/api/folders/${encodeURIComponent(folderName)}`, {
method: 'DELETE',
});
const result = await response.json();
@@ -390,7 +539,7 @@ class TranscriptionApp {
if (ext === '.md' || ext === '.txt') {
try {
- const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
+ const response = await Auth.apiFetch(`/api/files/content?path=${encodeURIComponent(path)}`);
const result = await response.json();
if (result.error) {
@@ -406,7 +555,7 @@ class TranscriptionApp {
${bodyHtml}
@@ -493,20 +642,20 @@ class TranscriptionApp {
async loadChatProjects() {
try {
- const response = await fetch('/api/rag/projects');
+ const response = await Auth.apiFetch('/api/rag/projects');
const result = await response.json();
- const select = document.getElementById('chatProjectSelect');
- select.innerHTML = '
';
- if (result.projects) {
- result.projects.forEach(p => {
- const opt = document.createElement('option');
- opt.value = p;
- opt.textContent = p;
- select.appendChild(opt);
- });
+ const projects = (result.projects || []).map(p => (
+ typeof p === 'string' ? { slug: p, name: p } : p
+ ));
+ this.populateProjectSelect('chatProjectSelect', projects, true);
+ const hint = document.getElementById('chatHint');
+ if (hint) {
+ hint.textContent = this.user?.all_projects_access
+ ? 'Выберите проект или «Все проекты» для поиска по организации.'
+ : 'Выберите проект, к которому у вас есть доступ.';
}
} catch (e) {
- console.error('Failed to load projects:', e);
+ console.error('Failed to load chat projects:', e);
}
}
@@ -520,6 +669,11 @@ class TranscriptionApp {
const select = document.getElementById('chatProjectSelect');
const project = select.value;
+ if (!project && !this.user?.all_projects_access) {
+ this.showToast('Выберите проект для поиска', 'error');
+ return;
+ }
+
input.value = '';
this.addChatBubble('user', question);
this.setChatThinking(true);
@@ -629,6 +783,131 @@ class TranscriptionApp {
this.chatHistory = [];
}
}
+
+ setupAdmin() {
+ if (!this.user?.is_admin) return;
+ const section = document.getElementById('adminSection');
+ if (section) section.hidden = false;
+ this.refreshAdminData();
+
+ document.getElementById('createUserForm')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const role = document.getElementById('newUserRole').value;
+ const projects = role === 'user'
+ ? document.getElementById('newUserProjects').value.split(',').map(s => s.trim()).filter(Boolean)
+ : [];
+ try {
+ const response = await Auth.apiFetch('/api/admin/users', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ username: document.getElementById('newUsername').value.trim(),
+ password: document.getElementById('newPassword').value,
+ role: document.getElementById('newUserRole').value,
+ projects,
+ }),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || 'Ошибка создания');
+ this.showToast(`Пользователь ${data.user.username} создан`, 'success');
+ e.target.reset();
+ this.toggleProjectsField();
+ this.refreshAdminData();
+ } catch (err) {
+ this.showToast(err.message, 'error');
+ }
+ });
+
+ document.getElementById('newUserRole')?.addEventListener('change', () => this.toggleProjectsField());
+ this.toggleProjectsField();
+
+ document.getElementById('createProjectForm')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ try {
+ const response = await Auth.apiFetch('/api/admin/projects', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ slug: document.getElementById('newProjectSlug').value.trim(),
+ name: document.getElementById('newProjectName').value.trim(),
+ }),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.detail || 'Ошибка');
+ this.showToast(`Проект ${data.project.slug} добавлен`, 'success');
+ e.target.reset();
+ await this.loadProjects();
+ this.refreshAdminData();
+ } catch (err) {
+ this.showToast(err.message, 'error');
+ }
+ });
+ }
+
+ toggleProjectsField() {
+ const role = document.getElementById('newUserRole')?.value;
+ const projectsInput = document.getElementById('newUserProjects');
+ const hint = document.getElementById('newUserProjectsHint');
+ const isUser = role === 'user';
+ if (projectsInput) {
+ projectsInput.disabled = !isUser;
+ projectsInput.placeholder = isUser
+ ? 'Проекты через запятую (2026, gp-merakom)'
+ : 'Не требуется — доступ ко всем проектам организации';
+ }
+ if (hint) {
+ hint.textContent = role === 'director'
+ ? 'Директор: все проекты, глобальный поиск, без панели администрирования.'
+ : role === 'admin'
+ ? 'Администратор: полный доступ + управление пользователями.'
+ : 'Укажите slug общих проектов org, назначенных администратором.';
+ }
+ }
+
+ async refreshAdminData() {
+ if (!this.user?.is_admin) return;
+ try {
+ const [usersRes, projectsRes] = await Promise.all([
+ Auth.apiFetch('/api/admin/users'),
+ Auth.apiFetch('/api/admin/projects'),
+ ]);
+ const usersData = await usersRes.json();
+ const projectsData = await projectsRes.json();
+
+ const usersEl = document.getElementById('adminUsersList');
+ if (usersEl) {
+ usersEl.innerHTML = (usersData.users || []).map(u => {
+ const shared = (u.shared_projects || u.projects || []).join(', ');
+ const owned = (u.owned_projects || []).join(', ');
+ const parts = [];
+ if (owned) parts.push(`личные: ${owned}`);
+ if (shared) parts.push(`общие: ${shared}`);
+ if (u.all_projects_access) parts.push('все проекты');
+ const projectsLabel = parts.join(' · ') || '—';
+ const roleLabel = { admin: 'admin', director: 'director', user: 'user' }[u.role] || u.role;
+ return `
+
+ ${this.escapeHtml(u.username)}
+ ${roleLabel}
+ ${this.escapeHtml(projectsLabel)}
+
+ `}).join('');
+ }
+
+ const projectsEl = document.getElementById('adminProjectsList');
+ if (projectsEl) {
+ projectsEl.innerHTML = (projectsData.projects || []).map(p => `
+
+ ${this.escapeHtml(p.slug)}
+ ${this.escapeHtml(p.name)}
+ ${p.scope === 'personal' ? 'личный' : 'org'}
+
+ `).join('');
+ }
+ } catch (e) {
+ console.error('Admin refresh failed:', e);
+ }
+ }
}
// Initialize
diff --git a/backend/static/auth.js b/backend/static/auth.js
new file mode 100644
index 0000000..536d3f0
--- /dev/null
+++ b/backend/static/auth.js
@@ -0,0 +1,106 @@
+/**
+ * Shared auth helpers for Transcriba frontend.
+ */
+
+const Auth = {
+ TOKEN_KEY: 'transcriba_token',
+ USER_KEY: 'transcriba_user',
+
+ getToken() {
+ return localStorage.getItem(this.TOKEN_KEY);
+ },
+
+ setSession(token, user) {
+ localStorage.setItem(this.TOKEN_KEY, token);
+ localStorage.setItem(this.USER_KEY, JSON.stringify(user));
+ },
+
+ getUser() {
+ try {
+ const raw = localStorage.getItem(this.USER_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+ },
+
+ clearSession() {
+ localStorage.removeItem(this.TOKEN_KEY);
+ localStorage.removeItem(this.USER_KEY);
+ localStorage.removeItem('transcriba_chat_history');
+ },
+
+ requireAuth() {
+ if (!this.getToken()) {
+ window.location.href = '/login';
+ return false;
+ }
+ return true;
+ },
+
+ authHeaders(extra = {}) {
+ const headers = { ...extra };
+ const token = this.getToken();
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+ return headers;
+ },
+
+ async apiFetch(url, options = {}) {
+ const headers = this.authHeaders(options.headers || {});
+ const response = await fetch(url, { ...options, headers });
+ if (response.status === 401) {
+ this.clearSession();
+ window.location.href = '/login';
+ throw new Error('Требуется авторизация');
+ }
+ return response;
+ },
+
+ async login(orgSlug, username, password) {
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ org_slug: orgSlug, username, password }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.detail || 'Ошибка входа');
+ }
+ this.setSession(data.access_token, data.user);
+ return data.user;
+ },
+
+ logout() {
+ this.clearSession();
+ window.location.href = '/login';
+ },
+
+ wsUrl(path = '/ws') {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const token = encodeURIComponent(this.getToken() || '');
+ return `${protocol}//${window.location.host}${path}?token=${token}`;
+ },
+};
+
+if (document.getElementById('loginForm')) {
+ document.getElementById('loginForm').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const btn = document.getElementById('loginBtn');
+ const errEl = document.getElementById('loginError');
+ errEl.hidden = true;
+ btn.disabled = true;
+ try {
+ await Auth.login(
+ document.getElementById('orgSlug').value.trim(),
+ document.getElementById('username').value.trim(),
+ document.getElementById('password').value
+ );
+ window.location.href = '/';
+ } catch (error) {
+ errEl.textContent = error.message;
+ errEl.hidden = false;
+ } finally {
+ btn.disabled = false;
+ }
+ });
+}
diff --git a/backend/static/index.html b/backend/static/index.html
index 5cbab4c..32a6d0e 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -6,20 +6,48 @@
Транскрибация совещаний
-
+
-
+
+
+
+
+
-
-
📁 Файлы
@@ -64,21 +90,18 @@
-
Здравствуйте! Я помогу найти информацию в протоколах совещаний.
-
Выберите проект или оставьте «Все проекты» для глобального поиска.
+
Выберите проект для поиска.
@@ -86,12 +109,41 @@
+
+
+ ⚙️ Администрирование
+
+
+
+
Проекты организации
+
+
+
+
+
-
-