Merge remote-tracking branch 'upstream/main' into feat/bookstack-connector

This commit is contained in:
Differ 2025-12-06 09:15:02 +08:00
commit e238fab638
110 changed files with 10076 additions and 1671 deletions

View file

@ -0,0 +1,123 @@
import logging
from typing import Any
import httpx
from app.config import config
logger = logging.getLogger(__name__)
async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
"""
Convert markdown to BlockNote JSON via Next.js API.
Args:
markdown: Markdown string to convert
Returns:
BlockNote document as dict, or None if conversion fails
"""
if not markdown or not markdown.strip():
logger.warning("Empty markdown provided for conversion")
return None
if not markdown or len(markdown) < 10:
logger.warning("Markdown became too short after sanitization")
# Return a minimal BlockNote document
return [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Document content could not be converted for editing.",
"styles": {},
}
],
"children": [],
}
]
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-blocknote",
json={"markdown": markdown},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
blocknote_document = data.get("blocknote_document")
if blocknote_document:
logger.info(
f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)"
)
return blocknote_document
else:
logger.warning("Next.js API returned empty blocknote_document")
return None
except httpx.TimeoutException:
logger.error("Timeout converting markdown to BlockNote after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}"
)
# Log first 1000 chars of problematic markdown for debugging
logger.debug(f"Problematic markdown sample: {markdown[:1000]}")
return None
except Exception as e:
logger.error(f"Failed to convert markdown to BlockNote: {e}", exc_info=True)
return None
async def convert_blocknote_to_markdown(
blocknote_document: dict[str, Any] | list[dict[str, Any]],
) -> str | None:
"""
Convert BlockNote JSON to markdown via Next.js API.
Args:
blocknote_document: BlockNote document as dict or list of blocks
Returns:
Markdown string, or None if conversion fails
"""
if not blocknote_document:
logger.warning("Empty BlockNote document provided for conversion")
return None
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-markdown",
json={"blocknote_document": blocknote_document},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
markdown = data.get("markdown")
if markdown:
logger.info(
f"Successfully converted BlockNote to markdown ({len(markdown)} chars)"
)
return markdown
else:
logger.warning("Next.js API returned empty markdown")
return None
except httpx.TimeoutException:
logger.error("Timeout converting BlockNote to markdown after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}"
)
return None
except Exception as e:
logger.error(f"Failed to convert BlockNote to markdown: {e}", exc_info=True)
return None

View file

@ -1,19 +0,0 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.db import User
# Helper function to check user ownership
async def check_ownership(session: AsyncSession, model, item_id: int, user: User):
item = await session.execute(
select(model).filter(model.id == item_id, model.user_id == user.id)
)
item = item.scalars().first()
if not item:
raise HTTPException(
status_code=404,
detail="Item not found or you don't have permission to access it",
)
return item

View file

@ -0,0 +1,274 @@
"""
RBAC (Role-Based Access Control) utility functions.
Provides helpers for checking user permissions in search spaces.
"""
import secrets
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from app.db import (
Permission,
SearchSpace,
SearchSpaceMembership,
SearchSpaceRole,
User,
has_permission,
)
async def get_user_membership(
session: AsyncSession,
user_id: UUID,
search_space_id: int,
) -> SearchSpaceMembership | None:
"""
Get the user's membership in a search space.
Args:
session: Database session
user_id: User UUID
search_space_id: Search space ID
Returns:
SearchSpaceMembership if found, None otherwise
"""
result = await session.execute(
select(SearchSpaceMembership)
.options(selectinload(SearchSpaceMembership.role))
.filter(
SearchSpaceMembership.user_id == user_id,
SearchSpaceMembership.search_space_id == search_space_id,
)
)
return result.scalars().first()
async def get_user_permissions(
session: AsyncSession,
user_id: UUID,
search_space_id: int,
) -> list[str]:
"""
Get the user's permissions in a search space.
Args:
session: Database session
user_id: User UUID
search_space_id: Search space ID
Returns:
List of permission strings
"""
membership = await get_user_membership(session, user_id, search_space_id)
if not membership:
return []
# Owners always have full access
if membership.is_owner:
return [Permission.FULL_ACCESS.value]
# Get permissions from role
if membership.role:
return membership.role.permissions or []
return []
async def check_permission(
session: AsyncSession,
user: User,
search_space_id: int,
required_permission: str,
error_message: str = "You don't have permission to perform this action",
) -> SearchSpaceMembership:
"""
Check if a user has a specific permission in a search space.
Raises HTTPException if permission is denied.
Args:
session: Database session
user: User object
search_space_id: Search space ID
required_permission: Permission string to check
error_message: Custom error message for permission denied
Returns:
SearchSpaceMembership if permission granted
Raises:
HTTPException: If user doesn't have access or permission
"""
membership = await get_user_membership(session, user.id, search_space_id)
if not membership:
raise HTTPException(
status_code=403,
detail="You don't have access to this search space",
)
# Get user's permissions
if membership.is_owner:
permissions = [Permission.FULL_ACCESS.value]
elif membership.role:
permissions = membership.role.permissions or []
else:
permissions = []
if not has_permission(permissions, required_permission):
raise HTTPException(status_code=403, detail=error_message)
return membership
async def check_search_space_access(
session: AsyncSession,
user: User,
search_space_id: int,
) -> SearchSpaceMembership:
"""
Check if a user has any access to a search space.
This is used for basic access control (user is a member).
Args:
session: Database session
user: User object
search_space_id: Search space ID
Returns:
SearchSpaceMembership if user has access
Raises:
HTTPException: If user doesn't have access
"""
membership = await get_user_membership(session, user.id, search_space_id)
if not membership:
raise HTTPException(
status_code=403,
detail="You don't have access to this search space",
)
return membership
async def is_search_space_owner(
session: AsyncSession,
user_id: UUID,
search_space_id: int,
) -> bool:
"""
Check if a user is the owner of a search space.
Args:
session: Database session
user_id: User UUID
search_space_id: Search space ID
Returns:
True if user is the owner, False otherwise
"""
membership = await get_user_membership(session, user_id, search_space_id)
return membership is not None and membership.is_owner
async def get_search_space_with_access_check(
session: AsyncSession,
user: User,
search_space_id: int,
required_permission: str | None = None,
) -> tuple[SearchSpace, SearchSpaceMembership]:
"""
Get a search space with access and optional permission check.
Args:
session: Database session
user: User object
search_space_id: Search space ID
required_permission: Optional permission to check
Returns:
Tuple of (SearchSpace, SearchSpaceMembership)
Raises:
HTTPException: If search space not found or user lacks access/permission
"""
# Get the search space
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
# Check access
if required_permission:
membership = await check_permission(
session, user, search_space_id, required_permission
)
else:
membership = await check_search_space_access(session, user, search_space_id)
return search_space, membership
def generate_invite_code() -> str:
"""
Generate a unique invite code for search space invites.
Returns:
A 32-character URL-safe invite code
"""
return secrets.token_urlsafe(24)
async def get_default_role(
session: AsyncSession,
search_space_id: int,
) -> SearchSpaceRole | None:
"""
Get the default role for a search space (used when accepting invites without a specific role).
Args:
session: Database session
search_space_id: Search space ID
Returns:
Default SearchSpaceRole or None
"""
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.is_default == True, # noqa: E712
)
)
return result.scalars().first()
async def get_owner_role(
session: AsyncSession,
search_space_id: int,
) -> SearchSpaceRole | None:
"""
Get the Owner role for a search space.
Args:
session: Database session
search_space_id: Search space ID
Returns:
Owner SearchSpaceRole or None
"""
result = await session.execute(
select(SearchSpaceRole).filter(
SearchSpaceRole.search_space_id == search_space_id,
SearchSpaceRole.name == "Owner",
)
)
return result.scalars().first()