""" LinkSyncServer - Link CRUD Endpoints """ import logging import uuid from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy import or_ from config.settings import settings from models.base import AuditLog, Bookmark, User, get_session router = APIRouter(prefix="/api/links", tags=["Links"]) 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) notes: Optional[str] = Field(None, max_length=2000) tags: Optional[List[str]] = Field(default_factory=list) favicon_url: Optional[str] = Field(None, max_length=512) path: Optional[str] = Field(None, max_length=512) visit_count: int = Field(0, ge=0) is_bookmarked: bool = Field(default=False) class BookmarkUpdate(BaseModel): url: Optional[str] = None 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]] = 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 def get_current_user_id(request: Request) -> Optional[str]: auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] try: import jwt payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) return payload.get("sub") except Exception: pass return None def parse_uuid(id_str: str): try: return uuid.UUID(id_str) if isinstance(id_str, str) else id_str except (ValueError, AttributeError): return None def log_audit(db, action: str, entity_type: str, entity_id: str, user_id: Optional[str], old_value=None, new_value=None): try: audit = AuditLog( action=action, entity_type=entity_type, entity_id=entity_id, old_value=old_value, new_value=new_value, user_id=user_id, ) db.add(audit) db.commit() except Exception: db.rollback() @router.get("/", response_model=List[dict]) async def list_bookmarks( limit: int = Query(20, le=100, ge=1), offset: int = Query(0, ge=0), search: Optional[str] = Query(None), tags_filter: Optional[List[str]] = Query(None), path_filter: Optional[str] = Query(None), ): db = get_session() try: query = db.query(Bookmark) if search: query = query.filter( or_( Bookmark.title.ilike(f"%{search}%"), Bookmark.description.ilike(f"%{search}%"), Bookmark.url.ilike(f"%{search}%"), ) ) if tags_filter: for tag in tags_filter: query = query.filter(Bookmark.tags.contains(tag)) if path_filter: query = query.filter(Bookmark.path.ilike(f"%{path_filter}%")) bookmarks = query.order_by(Bookmark.created_at.desc()).offset(offset).limit(limit).all() return [b.to_dict() for b in bookmarks] finally: db.close() @router.get("/{bookmark_id}", response_model=dict) async def get_bookmark(bookmark_id: str): db = get_session() try: parsed_id = parse_uuid(bookmark_id) if parsed_id is None: raise HTTPException(status_code=404, detail="Bookmark not found") bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first() if not bookmark: raise HTTPException(status_code=404, detail="Bookmark not found") return bookmark.to_dict() finally: db.close() @router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED) async def create_bookmark(data: BookmarkCreate, request: Request): db = get_session() try: user_id = None username = get_current_user_id(request) if username: user = db.query(User).filter(User.username == username).first() if user: user_id = user.id bookmark = Bookmark( id=uuid.uuid4(), 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, user_id=user_id, ) db.add(bookmark) db.commit() db.refresh(bookmark) log_audit(db, "create", "Bookmark", bookmark.id, user_id, new_value=bookmark.to_dict()) return bookmark.to_dict() finally: db.close() @router.put("/{bookmark_id}", response_model=dict) async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Request): db = get_session() try: parsed_id = parse_uuid(bookmark_id) if parsed_id is None: raise HTTPException(status_code=404, detail="Bookmark not found") bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first() if not bookmark: raise HTTPException(status_code=404, detail="Bookmark not found") old_value = bookmark.to_dict() update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(bookmark, field, value) db.commit() db.refresh(bookmark) username = get_current_user_id(request) user = db.query(User).filter(User.username == username).first() if username else None log_audit(db, "update", "Bookmark", bookmark.id, user.id if user else None, old_value=old_value, new_value=bookmark.to_dict()) return bookmark.to_dict() finally: db.close() @router.delete("/{bookmark_id}", response_model=dict) async def delete_bookmark(bookmark_id: str, request: Request): db = get_session() try: parsed_id = parse_uuid(bookmark_id) if parsed_id is None: raise HTTPException(status_code=404, detail="Bookmark not found") bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first() if not bookmark: raise HTTPException(status_code=404, detail="Bookmark not found") old_value = bookmark.to_dict() db.delete(bookmark) db.commit() username = get_current_user_id(request) user = db.query(User).filter(User.username == username).first() if username else None log_audit(db, "delete", "Bookmark", bookmark.id, user.id if user else None, old_value=old_value) return {"message": "Bookmark deleted successfully", "deleted_id": str(bookmark.id)} finally: db.close() class TagList(BaseModel): tags: List[str] @router.post("/{bookmark_id}/tags", response_model=dict) async def add_tags(bookmark_id: str, data: TagList, request: Request): db = get_session() try: parsed_id = parse_uuid(bookmark_id) if parsed_id is None: raise HTTPException(status_code=404, detail="Bookmark not found") bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first() if not bookmark: raise HTTPException(status_code=404, detail="Bookmark not found") current_tags = list(bookmark.tags or []) current_lower = [t.lower() for t in current_tags] for tag in data.tags: if tag.lower() not in current_lower: current_tags.append(tag) current_lower.append(tag.lower()) bookmark.tags = current_tags db.commit() db.refresh(bookmark) return bookmark.to_dict() finally: db.close() @router.delete("/{bookmark_id}/tags", response_model=dict) async def remove_tags(bookmark_id: str, data: TagList, request: Request): db = get_session() try: parsed_id = parse_uuid(bookmark_id) if parsed_id is None: raise HTTPException(status_code=404, detail="Bookmark not found") bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first() if not bookmark: raise HTTPException(status_code=404, detail="Bookmark not found") remove_lower = [t.lower() for t in data.tags] bookmark.tags = [t for t in (bookmark.tags or []) if t.lower() not in remove_lower] db.commit() db.refresh(bookmark) return bookmark.to_dict() finally: db.close()