mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 02:23:53 +02:00
Merge remote-tracking branch 'upstream/main' into feat/bookstack-connector
This commit is contained in:
commit
e238fab638
110 changed files with 10076 additions and 1671 deletions
123
surfsense_backend/app/utils/blocknote_converter.py
Normal file
123
surfsense_backend/app/utils/blocknote_converter.py
Normal 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
|
||||
|
|
@ -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
|
||||
274
surfsense_backend/app/utils/rbac.py
Normal file
274
surfsense_backend/app/utils/rbac.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue