Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions backend/app/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,45 @@
def optional_current_user(
request: Request, session: Session = Depends(get_session)
) -> User | None:
value = request.cookies.get(request.app.state.auth_service.cookie_name)
parsed = request.app.state.auth_service.read_cookie(value) if value else None
service = request.app.state.auth_service

api_key = request.headers.get("x-api-key")
if not api_key:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
bearer_token = auth_header[7:].strip()
if bearer_token.startswith("mf_"):
api_key = bearer_token
elif bearer_token:
parsed_cookie = service.read_cookie(bearer_token)
if not parsed_cookie:
return None
user_id, session_version = parsed_cookie
user = session.get(User, user_id)
if (
not user
or user.status != UserStatus.ACTIVE
or user.session_version != session_version
):
return None
return user

if api_key is not None:
parsed_key = service.parse_api_key(api_key)
if not parsed_key:
return None
user_id, normalized_key = parsed_key
user = session.get(User, user_id)
if (
not user
or user.status != UserStatus.ACTIVE
or not service.verify_api_key(normalized_key, user)
):
return None
return user

value = request.cookies.get(service.cookie_name)
parsed = service.read_cookie(value) if value else None
if not parsed:
return None

Expand All @@ -33,6 +70,9 @@ def current_user(
return user


get_current_user = current_user


def admin_user(user: User = Depends(current_user)) -> User:
if user.role != UserRole.ADMIN:
raise AppError(403, "admin_required", "需要管理员权限")
Expand Down
54 changes: 54 additions & 0 deletions backend/app/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.auth.models import User, UserRole, UserStatus
from app.auth.schemas import (
AdminCreateUserRequest,
ApiKeyResponse,
LoginRequest,
PasswordRequest,
RegisterRequest,
Expand Down Expand Up @@ -119,6 +120,59 @@ def session_status(
return {"user": public_user(user) if user else None}


@router.get("/api-key", response_model=ApiKeyResponse)
def get_api_key(
request: Request,
user: User = Depends(current_user),
) -> ApiKeyResponse:
service = request.app.state.auth_service
key = service.issue_api_key(user)
return ApiKeyResponse(
api_key=key,
token_prefix=f"{key[:12]}...",
user_id=user.id,
username=user.username,
)


@router.post("/api-key/rotate", response_model=ApiKeyResponse)
def rotate_api_key(
request: Request,
response: Response,
user: User = Depends(current_user),
session: Session = Depends(get_session),
) -> ApiKeyResponse:
service = request.app.state.auth_service
user.session_version += 1
session.commit()
session.refresh(user)
if request.cookies.get(service.cookie_name):
response.set_cookie(
service.cookie_name,
service.issue_cookie(user),
max_age=service.cookie_max_age,
httponly=True,
secure=request.app.state.settings.secure_cookies,
samesite="strict",
)
key = service.issue_api_key(user)
return ApiKeyResponse(
api_key=key,
token_prefix=f"{key[:12]}...",
user_id=user.id,
username=user.username,
)


v1_router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
v1_router.add_api_route(
"/api-key", get_api_key, methods=["GET"], response_model=ApiKeyResponse
)
v1_router.add_api_route(
"/api-key/rotate", rotate_api_key, methods=["POST"], response_model=ApiKeyResponse
)


@router.post("/change-password", status_code=204)
def change_password(
payload: PasswordRequest,
Expand Down
7 changes: 7 additions & 0 deletions backend/app/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ class UserResponse(BaseModel):
avatar_color: str
role: str
status: str


class ApiKeyResponse(BaseModel):
api_key: str
token_prefix: str
user_id: str
username: str
28 changes: 28 additions & 0 deletions backend/app/auth/service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from datetime import datetime, timezone
import hashlib
import hmac
import time

from itsdangerous import (
Expand Down Expand Up @@ -70,6 +72,32 @@ def read_cookie(self, value: str) -> tuple[str, int] | None:
return None
return str(payload["uid"]), int(payload["sv"])

def issue_api_key(self, user: User) -> str:
msg = f"meetflow-api-key-v1:{user.id}:{user.session_version}".encode("utf-8")
sig = hmac.new(
self.settings.app_secret_key.encode("utf-8"),
msg,
hashlib.sha256,
).hexdigest()[:32]
return f"mf_{user.id}_{sig}"

@staticmethod
def parse_api_key(raw_key: str) -> tuple[str, str] | None:
key = raw_key.strip()
if not key.startswith("mf_"):
return None
body = key[3:]
if "_" not in body:
return None
user_id, sig = body.rsplit("_", 1)
if not user_id or not sig:
return None
return user_id, key

def verify_api_key(self, api_key: str, user: User) -> bool:
expected = self.issue_api_key(user)
return hmac.compare_digest(api_key.strip(), expected)

def transition_user(
self,
session: Session,
Expand Down
10 changes: 9 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from app.agendas.router import router as agendas_router
from app.attachments.router import router as attachments_router
from app.attachments.storage import AttachmentStorage
from app.auth.router import admin_router, router as auth_router
from app.auth.router import admin_router, router as auth_router, v1_router as auth_v1_router
from app.calendar.router import router as calendar_router
from app.auth.service import AuthService
from app.config import Settings
Expand Down Expand Up @@ -97,6 +97,7 @@ async def lifespan(_app: FastAPI):
app.state.series_scheduler = series_scheduler
install_error_handlers(app)
app.include_router(auth_router)
app.include_router(auth_v1_router)
app.include_router(admin_router)
app.include_router(meetings_router)
app.include_router(agendas_router)
Expand All @@ -116,6 +117,13 @@ async def lifespan(_app: FastAPI):

@app.middleware("http")
async def reject_cross_origin_writes(request: Request, call_next):
path = request.scope.get("path", "")
if (
path.startswith("/api/v1/")
and not path.startswith("/api/v1/auth/api-key")
and not path.endswith("/ingest")
):
request.scope["path"] = "/api/" + path[len("/api/v1/") :]
origin = request.headers.get("origin", "").rstrip("/")
if (
request.method in {"POST", "PUT", "PATCH", "DELETE"}
Expand Down
140 changes: 139 additions & 1 deletion backend/app/meetings/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.orm import Session

from app.agendas.lifecycle import actual_duration_seconds
from app.agendas.models import AgendaItem
from app.agendas.outcome_tags import parse_outcome_tags
from app.agendas.service import AgendaService
from app.auth.dependencies import current_user
from app.auth.models import User
from app.collaboration.activity import ActivityRecorder
from app.database import get_session
from app.domain.enums import AgendaStatus, AgendaType
from app.errors import AppError
from app.http import utc_response
from app.meetings.policies import LifecyclePolicy
from app.meetings.schemas import (
AmendmentWrite,
LifecycleCommand,
Expand Down Expand Up @@ -347,3 +354,134 @@ async def notify_meeting_webhook(
session.commit()
return utc_response({"status": "ok", "response": res, "webhook_type": payload.webhook_type})


class MeetingIngestRequest(BaseModel):
agenda_title: str | None = Field(default=None, max_length=300)
agenda_item_id: str | int | None = None
notes_markdown: str = Field(min_length=1, max_length=200_000)
auto_complete_agenda: bool = False

@field_validator("notes_markdown")
@classmethod
def require_notes(cls, value: str) -> str:
if not value.strip():
raise ValueError("notes_markdown must not be blank")
return value


@router.post("/api/meetings/{meeting_id}/ingest")
@router.post("/api/v1/meetings/{meeting_id}/ingest")
def ingest_meeting_notes(
meeting_id: str,
payload: MeetingIngestRequest,
user: User = Depends(current_user),
session: Session = Depends(get_session),
) -> dict[str, Any]:
meeting = WorkspaceAccess(session).require_meeting_contribute(meeting_id, user)
LifecyclePolicy.require_mutable_status(meeting.status)
agenda_service = AgendaService(session)
tags = parse_outcome_tags(payload.notes_markdown)

target_id = (
str(payload.agenda_item_id).strip()
if payload.agenda_item_id is not None
else ""
)
if target_id:
item = agenda_service.get(target_id)
if item.meeting_id != meeting.id:
raise AppError(404, "agenda_item_not_found", "议题不存在")
if payload.agenda_title and payload.agenda_title.strip():
item.title = payload.agenda_title.strip()
item.notes_markdown = payload.notes_markdown
item.updated_by = user.id
item.version += 1
meeting.updated_by = user.id
meeting.version += 1
agenda_service._reconcile_derived_outcomes(item, tags, user)
else:
raw_title = (payload.agenda_title or "").strip()
title = raw_title or "AI 纪要导入"
items = agenda_service.list(meeting.id)
existing = next(
(row for row in items if raw_title and row.title.strip() == raw_title),
None,
)
if existing is not None:
item = existing
item.notes_markdown = payload.notes_markdown
item.updated_by = user.id
item.version += 1
meeting.updated_by = user.id
meeting.version += 1
agenda_service._reconcile_derived_outcomes(item, tags, user)
else:
item = AgendaItem(
meeting_id=meeting.id,
title=title,
agenda_type=AgendaType.discussion,
estimated_minutes=15,
notes_markdown=payload.notes_markdown,
status=AgendaStatus.planned,
position=len(items),
version=1,
created_by=user.id,
updated_by=user.id,
)
session.add(item)
meeting.version += 1
meeting.updated_by = user.id
session.flush()
agenda_service._reconcile_derived_outcomes(item, tags, user)

if payload.auto_complete_agenda and item.status in {
AgendaStatus.planned,
AgendaStatus.in_progress,
}:
now = utcnow()
if item.started_at is None:
item.started_at = now
item.status = AgendaStatus.completed
item.completed_at = now
item.actual_duration_seconds = actual_duration_seconds(item, now)

decisions_count = sum(1 for t in tags if t.kind == "decision")
actions_count = sum(1 for t in tags if t.kind == "action")
questions_count = sum(1 for t in tags if t.kind == "question")

ActivityRecorder(session).record(
project_id=meeting.project_id,
meeting_id=meeting.id,
actor_user_id=user.id,
event_type="meeting.notes_ingested",
subject_type="agenda_item",
subject_id=item.id,
payload={
"title": item.title,
"decisions": decisions_count,
"actions": actions_count,
"questions": questions_count,
},
)
session.commit()

service = MeetingService(session)
detail = service.meeting_detail(meeting_id, user)
capabilities = WorkspaceAccess(session).meeting_capabilities(meeting, user)
detail["capabilities"] = _capability_payload(capabilities)
return utc_response(
{
**detail,
"meeting": detail,
"ingested_agenda_item_id": item.id,
"decisions": decisions_count,
"actions": actions_count,
"questions": questions_count,
"extracted": {
"decisions": decisions_count,
"actions": actions_count,
"questions": questions_count,
},
}
)

Loading
Loading