""" LinkSyncServer - Link CRUD Endpoints with SQLAlchemy """ from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.orm import Session, sessionmaker from sqlalchemy import func, or_ from typing import List, Optional import uuid import logging import hashlib from models.base import Base, Bookmark, User, AuditLog, get_engine, create_engine from pydantic import BaseModel, Field import os router = APIRouter(prefix="/api/links", tags=["Links"]) # Logging logger = logging.getLogger(__name__) class BookmarkCreate(BaseModel): url: str = Field(..., description="Bookmark URL") title: str = Field(..., min_length=1, max_length=255, description="Bookmark title") description: Optional[str] = Field(None, max_length=500, description="Optional description") notes: Optional[str] = Field(None, max_length=2000, description="Optional notes") tags: Optional[List[str]] = Field(default_factory=list, description="List of tag names") favicon_url: Optional[str] = Field(None, max_length=512, description="Favicon URL") path: Optional[str] = Field(None, max_length=512, description="Folder path") visit_count: int = Field(ge=0, description="Visit counter") is_bookmarked: bool = Field(default=False, description="Bookmark flag") class BookmarkUpdate(BaseModel): url: Optional[str] = Field(None, description="New URL") title: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = Field(None, max_length=500) notes: Optional[str] = Field(None, max_length=2000) tags: Optional[List[str]] = Field(None) favicon_url: Optional[str] = Field(None, max_length=512) path: Optional[str] = Field(None, max_length=512) visit_count: Optional[int] = Field(None, ge=0) is_bookmarked: Optional[bool] = None class BookmarkResponse(BaseModel): id: str url: str title: str description: Optional[str] notes: Optional[str] tags: List[str] favicon_url: Optional[str] path: Optional[str] created_at: str updated_at: str visit_count: int is_bookmarked: bool source_set_id: Optional[str] user_id: Optional[str] def get_db_session(): """Get database session.""" try: return sessionmaker(get_engine())() except Exception: return None def get_current_user(request: Request): """Get current authenticated user.""" SECRET_KEY = os.environ.get("SECRET_KEY") auth_header = request.headers.get("Authorization") or request.headers.get("authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header[7:] try: import jwt payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) return {"username": payload.get("sub"), "id": payload.get("sub")} except Exception: pass return {"username": "guest"} @router.get("/", response_model=List[BookmarkResponse]) async def list_bookmarks( limit: int = Query(20, le=100, ge=1, description="Number of results per page"), offset: int = Query(0, ge=0, description="Offset for pagination"), search: Optional[str] = Query(None, description="Search query"), tags_filter: Optional[List[str]] = Query(None, description="Filter by tags"), path_filter: Optional[str] = Query(None, description="Filter by folder path") ): """List all bookmarks with optional filters.""" db = get_db_session() if not db: return [] query = Bookmark.query # Search filter if search: query = query.filter((Bookmark.title.contains(search)) | (Bookmark.description.contains(search)) | (Bookmark.url.contains(search))) # Tag filter if tags_filter: or_clause = or_(*[Bookmark.tags.contains(tag) for tag in tags_filter]) query = query.filter(or_clause) # Path filter if path_filter: query = query.filter(Bookmark.path.contains(path_filter)) bookmarks = query.limit(limit).offset(offset).all() return bookmarks @router.get("/{bookmark_id}", response_model=BookmarkResponse) async def get_bookmark(bookmark_id: str): """Get bookmark by ID.""" db = get_db_session() if not db: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found") bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() if not bookmark: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found") return bookmark @router.post("/", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED) async def create_bookmark(data: BookmarkCreate, request: Request): """Create new bookmark.""" db = get_db_session() if not db: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable") bookmark = Bookmark( url=data.url, title=data.title, description=data.description, notes=data.notes, tags=data.tags or [], favicon_url=data.favicon_url, path=data.path, visit_count=data.visit_count, is_bookmarked=data.is_bookmarked ) bookmark_id = f"{data.url[:20]}-{uuid.uuid4()[:8]}" bookmark = db.add(bookmark) db.commit() db.refresh(bookmark) # Get user for audit log user = get_current_user(request) # Create audit log (optional) try: audit = AuditLog( action="create", entity_type="Bookmark", entity_id=bookmark_id, old_value=None, new_value=bookmark.dict(), user_id=user.get("id") ) db.add(audit) db.commit() except Exception: pass return bookmark @router.put("/{bookmark_id}", response_model=BookmarkResponse) async def update_bookmark( bookmark_id: str, data: BookmarkUpdate, request: Request ): """Update bookmark.""" db = get_db_session() if not db: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable") bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() if not bookmark: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found") # Update fields for field_name, value in data.dict().items(): if value is not None: setattr(bookmark, field_name, value) db.commit() db.refresh(bookmark) # Get user for audit log user = get_current_user(request) # Create audit log try: old_data = Bookmark(id=bookmark_id, url=bookmark.url, title=bookmark.title).dict() audit = AuditLog( action="update", entity_type="Bookmark", entity_id=bookmark_id, old_value=old_data, new_value=bookmark.dict(), user_id=user.get("id") ) db.add(audit) db.commit() except Exception: pass return bookmark @router.delete("/{bookmark_id}", response_model=dict) async def delete_bookmark(bookmark_id: str, request: Request): """Delete bookmark.""" db = get_db_session() if not db: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable") bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() if not bookmark: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found") db.delete(bookmark) db.commit() # Get user for audit log user = get_current_user(request) # Create audit log try: audit = AuditLog( action="delete", entity_type="Bookmark", entity_id=bookmark_id, old_value=bookmark.dict(), new_value=None, user_id=user.get("id") ) db.add(audit) db.commit() except Exception: pass return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id} @router.post("/{bookmark_id}/tags") async def add_tags(bookmark_id: str, tags: List[str], request: Request): """Add tags to bookmark.""" db = get_db_session() if not db: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable") bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() if not bookmark: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found") for tag in tags: if tag.lower() not in [t.lower() for t in bookmark.tags]: bookmark.tags.append(tag) db.commit() db.refresh(bookmark) return bookmark @router.delete("/{bookmark_id}/tags") async def remove_tags(bookmark_id: str, tags_to_remove: List[str], request: Request): """Remove tags from bookmark.""" db = get_db_session() if not db: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable") bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() if not bookmark: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found") bookmark.tags = [t for t in bookmark.tags if t.lower() not in [tag.lower() for tag in tags_to_remove]] db.commit() db.refresh(bookmark) return bookmark @router.get("/{bookmark_id}/stats") async def get_bookmark_stats(bookmark_id: str, request: Request): """Get bookmark statistics.""" db = get_db_session() if not db: return {} bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() if not bookmark: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found") # Get visit count visits = db.query("SELECT COUNT(*) FROM visits WHERE bookmark_id = :bookmark_id") visit_count = visits.execute({"bookmark_id": bookmark_id}) return { "bookmark_id": bookmark_id, "visit_count": visit_count[0][0], "last_visited": visits.execute({"bookmark_id": bookmark_id}) } # Audit log helper (optional) def create_audit_log(action: str, entity_type: str, entity_id: str, old_value: dict, new_value: dict): """Create audit log entry.""" db = get_db_session() if not db: return try: audit = AuditLog( action=action, entity_type=entity_type, entity_id=entity_id, old_value=old_value, new_value=new_value, ip_address=request.client.host if hasattr(request, 'client') and hasattr(request.client, 'host') else None ) db.add(audit) db.commit() except Exception: pass