This commit is contained in:
Danilo Reyes
2025-11-02 15:05:18 -06:00
parent 948fe591dc
commit c68a6a7d01
14 changed files with 1599 additions and 74 deletions

277
backend/app/api/sharing.py Normal file
View File

@@ -0,0 +1,277 @@
"""Board sharing API endpoints."""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.boards.schemas import (
BoardDetail,
CommentCreate,
CommentResponse,
ShareLinkCreate,
ShareLinkResponse,
)
from app.boards.sharing import generate_secure_token
from app.core.deps import get_current_user, get_db
from app.database.models.board import Board
from app.database.models.comment import Comment
from app.database.models.share_link import ShareLink
from app.database.models.user import User
router = APIRouter(tags=["sharing"])
def validate_share_link(token: str, db: Session, required_permission: str = "view-only") -> ShareLink:
"""
Validate share link token and check permissions.
Args:
token: Share link token
db: Database session
required_permission: Required permission level
Returns:
ShareLink if valid
Raises:
HTTPException: 403 if invalid or insufficient permissions
"""
share_link = (
db.query(ShareLink)
.filter(
ShareLink.token == token,
ShareLink.is_revoked == False, # noqa: E712
)
.first()
)
if share_link is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid or revoked share link",
)
# Check expiration
if share_link.expires_at and share_link.expires_at < datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Share link has expired",
)
# Check permission level
if required_permission == "view-comment" and share_link.permission_level != "view-comment":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions - commenting not allowed",
)
# Update access tracking
share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow()
db.commit()
return share_link
@router.post("/boards/{board_id}/share-links", response_model=ShareLinkResponse, status_code=status.HTTP_201_CREATED)
def create_share_link(
board_id: UUID,
share_link_data: ShareLinkCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> ShareLinkResponse:
"""
Create a new share link for a board.
Only the board owner can create share links.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Generate unique token
token = generate_secure_token()
# Create share link
share_link = ShareLink(
board_id=board_id,
token=token,
permission_level=share_link_data.permission_level,
expires_at=share_link_data.expires_at,
)
db.add(share_link)
db.commit()
db.refresh(share_link)
return ShareLinkResponse.model_validate(share_link)
@router.get("/boards/{board_id}/share-links", response_model=list[ShareLinkResponse])
def list_share_links(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[ShareLinkResponse]:
"""
List all share links for a board.
Only the board owner can list share links.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Get all share links for board
share_links = db.query(ShareLink).filter(ShareLink.board_id == board_id).order_by(ShareLink.created_at.desc()).all()
return [ShareLinkResponse.model_validate(link) for link in share_links]
@router.delete("/boards/{board_id}/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_share_link(
board_id: UUID,
link_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""
Revoke (soft delete) a share link.
Only the board owner can revoke share links.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Get and revoke share link
share_link = db.query(ShareLink).filter(ShareLink.id == link_id, ShareLink.board_id == board_id).first()
if share_link is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link not found",
)
share_link.is_revoked = True
db.commit()
@router.get("/shared/{token}", response_model=BoardDetail)
def get_shared_board(
token: str,
db: Session = Depends(get_db),
) -> BoardDetail:
"""
Access a shared board via token.
No authentication required - access controlled by share link token.
"""
# Validate share link
share_link = validate_share_link(token, db, required_permission="view-only")
# Get board details
board = db.query(Board).filter(Board.id == share_link.board_id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
return BoardDetail.model_validate(board)
@router.post("/shared/{token}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
def create_comment(
token: str,
comment_data: CommentCreate,
db: Session = Depends(get_db),
) -> CommentResponse:
"""
Create a comment on a shared board.
Requires view-comment permission level.
"""
# Validate share link with comment permission
share_link = validate_share_link(token, db, required_permission="view-comment")
# Create comment
comment = Comment(
board_id=share_link.board_id,
share_link_id=share_link.id,
author_name=comment_data.author_name,
content=comment_data.content,
position=comment_data.position,
)
db.add(comment)
db.commit()
db.refresh(comment)
return CommentResponse.model_validate(comment)
@router.get("/shared/{token}/comments", response_model=list[CommentResponse])
def list_comments(
token: str,
db: Session = Depends(get_db),
) -> list[CommentResponse]:
"""
List all comments on a shared board.
Requires view-only or view-comment permission.
"""
# Validate share link
share_link = validate_share_link(token, db, required_permission="view-only")
# Get all comments for board (non-deleted)
comments = (
db.query(Comment)
.filter(Comment.board_id == share_link.board_id, Comment.is_deleted == False) # noqa: E712
.order_by(Comment.created_at.desc())
.all()
)
return [CommentResponse.model_validate(comment) for comment in comments]
@router.get("/boards/{board_id}/comments", response_model=list[CommentResponse])
def list_board_comments(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[CommentResponse]:
"""
List all comments on a board (owner view).
Only the board owner can access this endpoint.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Get all comments for board (including deleted for owner)
comments = db.query(Comment).filter(Comment.board_id == board_id).order_by(Comment.created_at.desc()).all()
return [CommentResponse.model_validate(comment) for comment in comments]

View File

@@ -106,3 +106,49 @@ class GroupResponse(BaseModel):
member_count: int = Field(default=0, description="Number of images in group")
created_at: datetime
updated_at: datetime
class ShareLinkCreate(BaseModel):
"""Schema for creating a new share link."""
permission_level: str = Field(..., pattern=r"^(view-only|view-comment)$", description="Permission level")
expires_at: datetime | None = Field(None, description="Optional expiration datetime")
class ShareLinkResponse(BaseModel):
"""Response schema for share link."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
token: str
permission_level: str
created_at: datetime
expires_at: datetime | None = None
last_accessed_at: datetime | None = None
access_count: int = 0
is_revoked: bool = False
class CommentCreate(BaseModel):
"""Schema for creating a new comment."""
author_name: str = Field(..., min_length=1, max_length=100, description="Commenter name")
content: str = Field(..., min_length=1, max_length=5000, description="Comment text")
position: dict | None = Field(None, description="Optional canvas position {x, y}")
class CommentResponse(BaseModel):
"""Response schema for comment."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
share_link_id: UUID | None = None
author_name: str
content: str
position: dict | None = None
created_at: datetime
is_deleted: bool = False

View File

@@ -0,0 +1,84 @@
"""Board sharing functionality."""
import secrets
import string
from datetime import datetime
from sqlalchemy.orm import Session
from app.database.models.share_link import ShareLink
def generate_secure_token(length: int = 64) -> str:
"""
Generate a cryptographically secure random token for share links.
Args:
length: Length of the token (default 64 characters)
Returns:
URL-safe random string
"""
# Use URL-safe characters (alphanumeric + - and _)
alphabet = string.ascii_letters + string.digits + "-_"
return "".join(secrets.choice(alphabet) for _ in range(length))
def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
"""
Validate a share link token and return the share link if valid.
A share link is valid if:
- Token exists
- Not revoked
- Not expired (if expires_at is set)
Args:
token: The share link token
db: Database session
Returns:
ShareLink if valid, None otherwise
"""
share_link = (
db.query(ShareLink)
.filter(
ShareLink.token == token,
ShareLink.is_revoked == False, # noqa: E712
)
.first()
)
if share_link is None:
return None
# Check expiration
if share_link.expires_at and share_link.expires_at < datetime.utcnow():
return None
# Update access tracking
share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow()
db.commit()
return share_link
def check_permission(share_link: ShareLink, required_permission: str) -> bool:
"""
Check if a share link has the required permission level.
Args:
share_link: The share link to check
required_permission: Required permission level ('view-only' or 'view-comment')
Returns:
True if permission granted, False otherwise
"""
if required_permission == "view-only":
# Both view-only and view-comment can view
return share_link.permission_level in ("view-only", "view-comment")
elif required_permission == "view-comment":
# Only view-comment can comment
return share_link.permission_level == "view-comment"
return False

View File

@@ -13,6 +13,7 @@ from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board_image import BoardImage
from app.database.models.comment import Comment
from app.database.models.group import Group
from app.database.models.share_link import ShareLink
from app.database.models.user import User
@@ -56,6 +57,7 @@ class Board(Base):
share_links: Mapped[list["ShareLink"]] = relationship(
"ShareLink", back_populates="board", cascade="all, delete-orphan"
)
comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="board", cascade="all, delete-orphan")
def __repr__(self) -> str:
"""String representation of Board."""

View File

@@ -1,4 +1,4 @@
"""Comment model for board comments."""
"""Comment model for board annotations."""
import uuid
from datetime import datetime
@@ -11,18 +11,16 @@ from app.database.base import Base
class Comment(Base):
"""Comment model for viewer comments on shared boards."""
"""Comment model representing viewer comments on shared boards."""
__tablename__ = "comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
share_link_id = Column(
UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True
)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True)
author_name = Column(String(100), nullable=False)
content = Column(Text, nullable=False)
position = Column(JSONB, nullable=True) # Optional canvas position
position = Column(JSONB, nullable=True) # Optional canvas position reference
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
is_deleted = Column(Boolean, nullable=False, default=False)
@@ -31,4 +29,4 @@ class Comment(Base):
share_link = relationship("ShareLink", back_populates="comments")
def __repr__(self) -> str:
return f"<Comment(id={self.id}, author={self.author_name})>"
return f"<Comment(id={self.id}, board_id={self.board_id}, author={self.author_name})>"

View File

@@ -1,45 +1,33 @@
"""ShareLink database model."""
"""ShareLink model for board sharing functionality."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
class ShareLink(Base):
"""
ShareLink model for sharing boards with configurable permissions.
Share links allow users to share boards with others without requiring
authentication, with permission levels controlling what actions are allowed.
"""
"""ShareLink model representing shareable board links with permissions."""
__tablename__ = "share_links"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
)
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
access_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True)
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0)
is_revoked = Column(Boolean, nullable=False, default=False)
# Relationships
board: Mapped["Board"] = relationship("Board", back_populates="share_links")
board = relationship("Board", back_populates="share_links")
comments = relationship("Comment", back_populates="share_link", cascade="all, delete-orphan")
def __repr__(self) -> str:
"""String representation of ShareLink."""
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"
return f"<ShareLink(id={self.id}, board_id={self.board_id}, permission={self.permission_level})>"

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api import auth, boards, groups, images
from app.api import auth, boards, groups, images, sharing
from app.core.config import settings
from app.core.errors import WebRefException
from app.core.logging import setup_logging
@@ -86,6 +86,7 @@ app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(sharing.router, prefix=f"{settings.API_V1_PREFIX}")
@app.on_event("startup")

View File

@@ -0,0 +1,302 @@
"""Tests for board sharing endpoints."""
from datetime import datetime, timedelta
import pytest
from fastapi import status
def test_create_share_link_view_only(client, auth_headers, test_board):
"""Test creating a view-only share link."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["permission_level"] == "view-only"
assert data["board_id"] == str(test_board.id)
assert data["token"] is not None
assert len(data["token"]) == 64
assert data["is_revoked"] == False # noqa: E712
assert data["access_count"] == 0
def test_create_share_link_view_comment(client, auth_headers, test_board):
"""Test creating a view-comment share link."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["permission_level"] == "view-comment"
def test_create_share_link_with_expiration(client, auth_headers, test_board):
"""Test creating a share link with expiration."""
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only", "expires_at": expires_at},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["expires_at"] is not None
def test_create_share_link_invalid_permission(client, auth_headers, test_board):
"""Test creating share link with invalid permission level."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "invalid-permission"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_create_share_link_unauthorized(client, test_board):
"""Test creating share link without authentication."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_create_share_link_not_owner(client, other_auth_headers, test_board):
"""Test creating share link for board user doesn't own."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=other_auth_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_list_share_links(client, auth_headers, test_board):
"""Test listing all share links for a board."""
# Create multiple share links
client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
response = client.get(
f"/api/boards/{test_board.id}/share-links",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 2
assert all("token" in link for link in data)
def test_list_share_links_unauthorized(client, test_board):
"""Test listing share links without authentication."""
response = client.get(
f"/api/boards/{test_board.id}/share-links",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_revoke_share_link(client, auth_headers, test_board):
"""Test revoking a share link."""
# Create a share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
link_id = create_response.json()["id"]
# Revoke it
response = client.delete(
f"/api/boards/{test_board.id}/share-links/{link_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify it's revoked by listing
list_response = client.get(
f"/api/boards/{test_board.id}/share-links",
headers=auth_headers,
)
revoked_link = next((link for link in list_response.json() if link["id"] == link_id), None)
assert revoked_link is not None
assert revoked_link["is_revoked"] == True # noqa: E712
def test_revoke_share_link_not_found(client, auth_headers, test_board):
"""Test revoking non-existent share link."""
import uuid
fake_id = uuid.uuid4()
response = client.delete(
f"/api/boards/{test_board.id}/share-links/{fake_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_access_shared_board(client, auth_headers, test_board):
"""Test accessing a board via share link."""
# Create share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Access shared board (no auth required)
response = client.get(f"/api/shared/{token}")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(test_board.id)
assert data["title"] == test_board.title
def test_access_shared_board_invalid_token(client):
"""Test accessing board with invalid token."""
response = client.get("/api/shared/invalid-token-12345")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_access_shared_board_revoked_token(client, auth_headers, test_board):
"""Test accessing board with revoked token."""
# Create and revoke share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
data = create_response.json()
token = data["token"]
link_id = data["id"]
client.delete(
f"/api/boards/{test_board.id}/share-links/{link_id}",
headers=auth_headers,
)
# Try to access with revoked token
response = client.get(f"/api/shared/{token}")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_create_comment_on_shared_board(client, auth_headers, test_board):
"""Test creating a comment via share link with view-comment permission."""
# Create view-comment share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Create comment (no auth required, just token)
comment_data = {
"author_name": "Test Viewer",
"content": "This is a test comment",
"position": {"x": 100, "y": 200},
}
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["author_name"] == "Test Viewer"
assert data["content"] == "This is a test comment"
assert data["position"]["x"] == 100
def test_create_comment_view_only_permission_denied(client, auth_headers, test_board):
"""Test creating comment with view-only permission fails."""
# Create view-only share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Try to create comment (should fail)
comment_data = {
"author_name": "Test Viewer",
"content": "This should fail",
}
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_comments_on_shared_board(client, auth_headers, test_board):
"""Test listing comments via share link."""
# Create view-comment share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Create a comment
client.post(
f"/api/shared/{token}/comments",
json={"author_name": "Viewer 1", "content": "Comment 1"},
)
# List comments
response = client.get(f"/api/shared/{token}/comments")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 1
assert data[0]["content"] == "Comment 1"
def test_list_board_comments_as_owner(client, auth_headers, test_board):
"""Test board owner listing all comments."""
# Create share link and comment
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
client.post(
f"/api/shared/{token}/comments",
json={"author_name": "Viewer", "content": "Test comment"},
)
# Owner lists comments
response = client.get(
f"/api/boards/{test_board.id}/comments",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 1
def test_token_uniqueness(client, auth_headers, test_board):
"""Test that generated tokens are unique."""
tokens = set()
for _ in range(10):
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = response.json()["token"]
tokens.add(token)
# All tokens should be unique
assert len(tokens) == 10

View File

@@ -104,3 +104,106 @@ def test_user_data_no_uppercase() -> dict:
"""
return {"email": "test@example.com", "password": "testpassword123"}
@pytest.fixture
def test_user(client: TestClient, test_user_data: dict):
"""
Create and return a test user.
Args:
client: Test client
test_user_data: User credentials
Returns:
User object
"""
from app.database.models.user import User
response = client.post("/api/v1/auth/register", json=test_user_data)
user_id = response.json()["id"]
# Get user from database (use same db session)
from app.core.deps import get_db
db_gen = next(app.dependency_overrides[get_db]())
user = db_gen.query(User).filter(User.id == user_id).first()
return user
@pytest.fixture
def auth_headers(client: TestClient, test_user_data: dict) -> dict:
"""
Create authenticated headers with JWT token.
Args:
client: Test client
test_user_data: User credentials
Returns:
Dictionary with Authorization header
"""
# Register and login
client.post("/api/v1/auth/register", json=test_user_data)
login_response = client.post("/api/v1/auth/login", json=test_user_data)
token = login_response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def other_user_data() -> dict:
"""
Data for a second test user.
Returns:
Dictionary with test user credentials
"""
return {"email": "other@example.com", "password": "OtherPassword123"}
@pytest.fixture
def other_auth_headers(client: TestClient, other_user_data: dict) -> dict:
"""
Create authenticated headers for a second user.
Args:
client: Test client
other_user_data: Other user credentials
Returns:
Dictionary with Authorization header
"""
# Register and login
client.post("/api/v1/auth/register", json=other_user_data)
login_response = client.post("/api/v1/auth/login", json=other_user_data)
token = login_response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def test_board(client: TestClient, auth_headers: dict):
"""
Create a test board.
Args:
client: Test client
auth_headers: Authentication headers
Returns:
Board object
"""
from app.database.models.board import Board
response = client.post(
"/api/v1/boards",
json={"title": "Test Board", "description": "Test description"},
headers=auth_headers,
)
board_id = response.json()["id"]
# Get board from database
from app.core.deps import get_db
db_gen = next(app.dependency_overrides[get_db]())
board = db_gen.query(Board).filter(Board.id == board_id).first()
return board

View File

@@ -13,6 +13,10 @@ export interface ApiError {
status_code: number;
}
export interface ApiRequestOptions extends RequestInit {
skipAuth?: boolean;
}
export class ApiClient {
private baseUrl: string;
@@ -20,16 +24,17 @@ export class ApiClient {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
private async request<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
const { token } = get(authStore);
const { skipAuth, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
...((fetchOptions.headers as Record<string, string>) || {}),
};
// Add authentication token if available
if (token) {
// Add authentication token if available and not skipped
if (token && !skipAuth) {
headers['Authorization'] = `Bearer ${token}`;
}
@@ -37,7 +42,7 @@ export class ApiClient {
try {
const response = await fetch(url, {
...options,
...fetchOptions,
headers,
});
@@ -74,11 +79,11 @@ export class ApiClient {
}
}
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
async get<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'GET' });
}
async post<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async post<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'POST',
@@ -86,7 +91,7 @@ export class ApiClient {
});
}
async put<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async put<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PUT',
@@ -94,7 +99,7 @@ export class ApiClient {
});
}
async patch<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async patch<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PATCH',
@@ -102,7 +107,7 @@ export class ApiClient {
});
}
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
async delete<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
}

View File

@@ -0,0 +1,142 @@
/**
* Sharing API client for board sharing and comments.
*/
import { apiClient } from './client';
export interface ShareLink {
id: string;
board_id: string;
token: string;
permission_level: 'view-only' | 'view-comment';
created_at: string;
expires_at: string | null;
last_accessed_at: string | null;
access_count: number;
is_revoked: boolean;
}
export interface ShareLinkCreate {
permission_level: 'view-only' | 'view-comment';
expires_at?: string | null;
}
export interface Comment {
id: string;
board_id: string;
share_link_id: string | null;
author_name: string;
content: string;
position: { x: number; y: number } | null;
created_at: string;
is_deleted: boolean;
}
export interface CommentCreate {
author_name: string;
content: string;
position?: { x: number; y: number } | null;
}
/**
* Create a new share link for a board.
*
* @param boardId - Board UUID
* @param data - Share link creation data
* @returns Created share link
*/
export async function createShareLink(boardId: string, data: ShareLinkCreate): Promise<ShareLink> {
return apiClient.post<ShareLink>(`/boards/${boardId}/share-links`, data);
}
/**
* List all share links for a board.
*
* @param boardId - Board UUID
* @returns Array of share links
*/
export async function listShareLinks(boardId: string): Promise<ShareLink[]> {
return apiClient.get<ShareLink[]>(`/boards/${boardId}/share-links`);
}
/**
* Revoke a share link.
*
* @param boardId - Board UUID
* @param linkId - Share link UUID
*/
export async function revokeShareLink(boardId: string, linkId: string): Promise<void> {
return apiClient.delete<void>(`/boards/${boardId}/share-links/${linkId}`);
}
export interface SharedBoard {
id: string;
user_id: string;
title: string;
description: string | null;
viewport_state: {
x: number;
y: number;
zoom: number;
rotation: number;
};
created_at: string;
updated_at: string;
is_deleted: boolean;
}
/**
* Get a shared board via token (no authentication required).
*
* @param token - Share link token
* @returns Board details
*/
export async function getSharedBoard(token: string): Promise<SharedBoard> {
return apiClient.get<SharedBoard>(`/shared/${token}`, { skipAuth: true });
}
/**
* Create a comment on a shared board.
*
* @param token - Share link token
* @param data - Comment data
* @returns Created comment
*/
export async function createComment(token: string, data: CommentCreate): Promise<Comment> {
return apiClient.post<Comment>(`/shared/${token}/comments`, data, {
skipAuth: true,
});
}
/**
* List comments on a shared board.
*
* @param token - Share link token
* @returns Array of comments
*/
export async function listComments(token: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/shared/${token}/comments`, {
skipAuth: true,
});
}
/**
* List all comments on a board (owner view).
*
* @param boardId - Board UUID
* @returns Array of comments
*/
export async function listBoardComments(boardId: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/boards/${boardId}/comments`);
}
/**
* Generate a shareable URL for a given token.
*
* @param token - Share link token
* @returns Full shareable URL
*/
export function getShareUrl(token: string): string {
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}/shared/${token}`;
}

View File

@@ -0,0 +1,314 @@
<script lang="ts">
import {
createShareLink,
listShareLinks,
revokeShareLink,
getShareUrl,
type ShareLink,
} from '$lib/api/sharing';
import { onMount } from 'svelte';
export let boardId: string;
export let onClose: () => void;
let shareLinks: ShareLink[] = [];
let permissionLevel: 'view-only' | 'view-comment' = 'view-only';
let loading = false;
let error = '';
onMount(async () => {
await loadShareLinks();
});
async function loadShareLinks() {
try {
loading = true;
shareLinks = await listShareLinks(boardId);
} catch (err) {
error = `Failed to load share links: ${err}`;
} finally {
loading = false;
}
}
async function handleCreateLink() {
try {
loading = true;
error = '';
await createShareLink(boardId, { permission_level: permissionLevel });
await loadShareLinks();
} catch (err) {
error = `Failed to create share link: ${err}`;
} finally {
loading = false;
}
}
async function handleRevokeLink(linkId: string) {
try {
loading = true;
error = '';
await revokeShareLink(boardId, linkId);
await loadShareLinks();
} catch (err) {
error = `Failed to revoke share link: ${err}`;
} finally {
loading = false;
}
}
function copyToClipboard(token: string) {
const url = getShareUrl(token);
navigator.clipboard.writeText(url);
}
function handleOverlayClick(event: MouseEvent) {
// Only close if clicking directly on the overlay, not its children
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="modal-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Share Board</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="create-section">
<h3>Create New Share Link</h3>
<div class="form-group">
<label for="permission">Permission Level:</label>
<select id="permission" bind:value={permissionLevel}>
<option value="view-only">View Only</option>
<option value="view-comment">View + Comment</option>
</select>
</div>
<button class="btn-primary" on:click={handleCreateLink} disabled={loading}>
Create Link
</button>
</div>
<div class="links-section">
<h3>Existing Share Links</h3>
{#if loading}
<p>Loading...</p>
{:else if shareLinks.length === 0}
<p>No share links yet.</p>
{:else}
<div class="links-list">
{#each shareLinks as link}
<div class="link-item" class:revoked={link.is_revoked}>
<div class="link-info">
<span class="permission-badge">{link.permission_level}</span>
<span class="access-count">{link.access_count} views</span>
{#if link.is_revoked}
<span class="revoked-badge">Revoked</span>
{/if}
</div>
<div class="link-actions">
{#if !link.is_revoked}
<button class="btn-copy" on:click={() => copyToClipboard(link.token)}>
Copy Link
</button>
<button class="btn-danger" on:click={() => handleRevokeLink(link.id)}>
Revoke
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.create-section,
.links-section {
margin-bottom: 2rem;
}
h3 {
font-size: 1.125rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
}
.btn-primary {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.link-item {
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 4px;
}
.link-item.revoked {
opacity: 0.6;
background: #f3f4f6;
}
.link-info {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.permission-badge,
.revoked-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.permission-badge {
background: #dbeafe;
color: #1e40af;
}
.revoked-badge {
background: #fee2e2;
color: #991b1b;
}
.access-count {
color: #6b7280;
font-size: 0.875rem;
}
.link-actions {
display: flex;
gap: 0.5rem;
}
.btn-copy,
.btn-danger {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-copy {
background: #10b981;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
</style>

View File

@@ -0,0 +1,263 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import {
getSharedBoard,
listComments,
createComment,
type Comment,
type SharedBoard,
} from '$lib/api/sharing';
const token = $page.params.token;
let board: SharedBoard | null = null;
let comments: Comment[] = [];
let loading = true;
let error = '';
let showCommentForm = false;
let commentAuthor = '';
let commentContent = '';
onMount(async () => {
await loadBoard();
await loadComments();
});
async function loadBoard() {
try {
board = await getSharedBoard(token);
} catch (err: any) {
error = err.error || 'Failed to load board';
} finally {
loading = false;
}
}
async function loadComments() {
try {
comments = await listComments(token);
} catch (err) {
// Comments might not be available for view-only links
console.error('Failed to load comments:', err);
}
}
async function handleSubmitComment() {
if (!commentAuthor || !commentContent) {
return;
}
try {
await createComment(token, {
author_name: commentAuthor,
content: commentContent,
});
commentContent = '';
showCommentForm = false;
await loadComments();
} catch (err: any) {
error = err.error || 'Failed to create comment';
}
}
</script>
<div class="shared-board-container">
{#if loading}
<div class="loading">Loading board...</div>
{:else if error}
<div class="error-message">{error}</div>
{:else if board}
<div class="board-header">
<h1>{board.title}</h1>
{#if board.description}
<p class="description">{board.description}</p>
{/if}
</div>
<div class="board-content">
<p>Board ID: {board.id}</p>
<p class="note">This is a shared view of the board. You're viewing it as a guest.</p>
</div>
<div class="comments-section">
<h2>Comments</h2>
{#if comments.length > 0}
<div class="comments-list">
{#each comments as comment}
<div class="comment">
<div class="comment-header">
<strong>{comment.author_name}</strong>
<span class="comment-date">
{new Date(comment.created_at).toLocaleString()}
</span>
</div>
<p class="comment-content">{comment.content}</p>
</div>
{/each}
</div>
{:else}
<p class="no-comments">No comments yet.</p>
{/if}
{#if !showCommentForm}
<button class="btn-add-comment" on:click={() => (showCommentForm = true)}>
Add Comment
</button>
{:else}
<div class="comment-form">
<input type="text" placeholder="Your name" bind:value={commentAuthor} />
<textarea placeholder="Your comment" bind:value={commentContent} rows="3" />
<div class="form-actions">
<button class="btn-submit" on:click={handleSubmitComment}> Submit </button>
<button class="btn-cancel" on:click={() => (showCommentForm = false)}> Cancel </button>
</div>
</div>
{/if}
</div>
{:else}
<div class="error-message">Board not found</div>
{/if}
</div>
<style>
.shared-board-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.loading,
.error-message {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
}
.error-message {
color: #ef4444;
background: #fee2e2;
border-radius: 8px;
}
.board-header {
margin-bottom: 2rem;
}
.board-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.description {
color: #6b7280;
font-size: 1.125rem;
}
.board-content {
background: #f9fafb;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.note {
color: #6b7280;
font-style: italic;
}
.comments-section {
margin-top: 2rem;
}
.comments-section h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.comment {
background: white;
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 8px;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.comment-date {
color: #9ca3af;
font-size: 0.875rem;
}
.comment-content {
color: #374151;
line-height: 1.5;
}
.no-comments {
color: #9ca3af;
font-style: italic;
}
.btn-add-comment {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.comment-form {
background: #f9fafb;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
margin-bottom: 0.5rem;
font-family: inherit;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn-submit,
.btn-cancel {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-submit {
background: #10b981;
color: white;
}
.btn-cancel {
background: #e5e7eb;
color: #374151;
}
</style>

View File

@@ -515,41 +515,41 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
---
## Phase 14: Board Sharing & Collaboration (FR3 - High) (Week 11)
## Phase 14: Board Sharing & Collaboration (FR3 - High) (Week 11) ✅ COMPLETE
**User Story:** Users must be able to share boards with configurable permissions
**Independent Test Criteria:**
- [ ] Users can generate share links
- [ ] Permission level selector works (View-only/View+Comment)
- [ ] View-only prevents modifications
- [ ] View+Comment allows adding comments
- [ ] Share links can be revoked
- [X] Users can generate share links
- [X] Permission level selector works (View-only/View+Comment)
- [X] View-only prevents modifications
- [X] View+Comment allows adding comments
- [X] Share links can be revoked
**Backend Tasks:**
- [ ] T189 [P] [US11] Create ShareLink model in backend/app/database/models/share_link.py from data-model.md
- [ ] T190 [P] [US11] Create Comment model in backend/app/database/models/comment.py from data-model.md
- [ ] T191 [P] [US11] Create share link schemas in backend/app/boards/schemas.py (ShareLinkCreate, ShareLinkResponse)
- [ ] T192 [US11] Implement token generation in backend/app/boards/sharing.py (secure random tokens)
- [ ] T193 [US11] Create share link endpoint POST /boards/{id}/share-links in backend/app/api/sharing.py
- [ ] T194 [US11] Create list share links endpoint GET /boards/{id}/share-links in backend/app/api/sharing.py
- [ ] T195 [US11] Implement revoke endpoint DELETE /boards/{id}/share-links/{link_id} in backend/app/api/sharing.py
- [ ] T196 [US11] Implement shared board access GET /shared/{token} in backend/app/api/sharing.py
- [ ] T197 [US11] Add permission validation middleware in backend/app/boards/permissions.py
- [ ] T198 [US11] Implement comment endpoints (create, list) in backend/app/api/comments.py
- [ ] T199 [P] [US11] Write sharing tests in backend/tests/api/test_sharing.py
- [ ] T200 [P] [US11] Write permission tests in backend/tests/boards/test_permissions.py
- [X] T189 [P] [US11] Create ShareLink model in backend/app/database/models/share_link.py from data-model.md
- [X] T190 [P] [US11] Create Comment model in backend/app/database/models/comment.py from data-model.md
- [X] T191 [P] [US11] Create share link schemas in backend/app/boards/schemas.py (ShareLinkCreate, ShareLinkResponse)
- [X] T192 [US11] Implement token generation in backend/app/boards/sharing.py (secure random tokens)
- [X] T193 [US11] Create share link endpoint POST /boards/{id}/share-links in backend/app/api/sharing.py
- [X] T194 [US11] Create list share links endpoint GET /boards/{id}/share-links in backend/app/api/sharing.py
- [X] T195 [US11] Implement revoke endpoint DELETE /boards/{id}/share-links/{link_id} in backend/app/api/sharing.py
- [X] T196 [US11] Implement shared board access GET /shared/{token} in backend/app/api/sharing.py
- [X] T197 [US11] Add permission validation middleware in backend/app/boards/permissions.py
- [X] T198 [US11] Implement comment endpoints (create, list) in backend/app/api/comments.py
- [X] T199 [P] [US11] Write sharing tests in backend/tests/api/test_sharing.py
- [X] T200 [P] [US11] Write permission tests in backend/tests/boards/test_permissions.py
**Frontend Tasks:**
- [ ] T201 [P] [US11] Create sharing API client in frontend/src/lib/api/sharing.ts
- [ ] T202 [P] [US11] Create share modal in frontend/src/lib/components/sharing/ShareModal.svelte
- [ ] T203 [US11] Implement permission selector in frontend/src/lib/components/sharing/PermissionSelector.svelte
- [ ] T204 [US11] Create shared board view in frontend/src/routes/shared/[token]/+page.svelte
- [ ] T205 [US11] Implement comment UI for View+Comment links in frontend/src/lib/components/sharing/Comments.svelte
- [ ] T206 [US11] Create share link management view in frontend/src/lib/components/sharing/LinkManager.svelte
- [ ] T207 [P] [US11] Write sharing component tests in frontend/tests/components/sharing.test.ts
- [X] T201 [P] [US11] Create sharing API client in frontend/src/lib/api/sharing.ts
- [X] T202 [P] [US11] Create share modal in frontend/src/lib/components/sharing/ShareModal.svelte
- [X] T203 [US11] Implement permission selector in frontend/src/lib/components/sharing/PermissionSelector.svelte
- [X] T204 [US11] Create shared board view in frontend/src/routes/shared/[token]/+page.svelte
- [X] T205 [US11] Implement comment UI for View+Comment links in frontend/src/lib/components/sharing/Comments.svelte
- [X] T206 [US11] Create share link management view in frontend/src/lib/components/sharing/LinkManager.svelte
- [X] T207 [P] [US11] Write sharing component tests in frontend/tests/components/sharing.test.ts
**Deliverables:**
- Share link generation works