diff --git a/LinkSyncServer/api/endpoints/admin.py b/LinkSyncServer/api/endpoints/admin.py index 37c262d..7c32d5d 100644 --- a/LinkSyncServer/api/endpoints/admin.py +++ b/LinkSyncServer/api/endpoints/admin.py @@ -5,7 +5,7 @@ LinkSyncServer - Admin Endpoints import uuid from typing import List, Optional -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, EmailStr, Field from api.endpoints.auth import hash_password, require_admin @@ -34,11 +34,18 @@ class SettingsUpdate(BaseModel): cors_origins: Optional[str] = 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 + + @router.get("/users", response_model=List[dict]) async def list_users( limit: int = Query(20, le=100, ge=1), offset: int = Query(0, ge=0), - current_admin: dict = require_admin, + current_admin: dict = Depends(require_admin), ): db = get_session() try: @@ -51,7 +58,7 @@ async def list_users( @router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED) async def create_user( data: UserCreate, - current_admin: dict = require_admin, + current_admin: dict = Depends(require_admin), ): db = get_session() try: @@ -62,7 +69,7 @@ async def create_user( raise HTTPException(status_code=400, detail="Username or email already exists") user = User( - id=str(uuid.uuid4()), + id=uuid.uuid4(), username=data.username, email=data.email, password_hash=hash_password(data.password), @@ -80,11 +87,14 @@ async def create_user( @router.get("/users/{user_id}", response_model=dict) async def get_user( user_id: str, - current_admin: dict = require_admin, + current_admin: dict = Depends(require_admin), ): db = get_session() try: - user = db.query(User).filter(User.id == user_id).first() + parsed_id = parse_uuid(user_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="User not found") + user = db.query(User).filter(User.id == parsed_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") return user.to_dict() @@ -96,11 +106,14 @@ async def get_user( async def update_user( user_id: str, data: UserUpdate, - current_admin: dict = require_admin, + current_admin: dict = Depends(require_admin), ): db = get_session() try: - user = db.query(User).filter(User.id == user_id).first() + parsed_id = parse_uuid(user_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="User not found") + user = db.query(User).filter(User.id == parsed_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") @@ -121,11 +134,14 @@ async def update_user( @router.delete("/users/{user_id}", response_model=dict) async def delete_user( user_id: str, - current_admin: dict = require_admin, + current_admin: dict = Depends(require_admin), ): db = get_session() try: - user = db.query(User).filter(User.id == user_id).first() + parsed_id = parse_uuid(user_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="User not found") + user = db.query(User).filter(User.id == parsed_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") if user.username == current_admin.get("username"): @@ -139,7 +155,7 @@ async def delete_user( @router.get("/stats", response_model=dict) -async def get_system_stats(current_admin: dict = require_admin): +async def get_system_stats(current_admin: dict = Depends(require_admin)): db = get_session() try: return { @@ -159,7 +175,7 @@ async def get_audit_log( offset: int = Query(0, ge=0), entity_type: Optional[str] = Query(None), action: Optional[str] = Query(None), - current_admin: dict = require_admin, + current_admin: dict = Depends(require_admin), ): db = get_session() try: @@ -171,14 +187,14 @@ async def get_audit_log( logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all() return [ { - "id": log.id, - "user_id": log.user_id, + "id": str(log.id), + "user_id": str(log.user_id) if log.user_id else None, "action": log.action, "entity_type": log.entity_type, - "entity_id": log.entity_id, + "entity_id": str(log.entity_id) if log.entity_id else None, "old_value": log.old_value, "new_value": log.new_value, - "ip_address": log.ip_address, + "ip_address": str(log.ip_address) if log.ip_address else None, "created_at": log.created_at.isoformat() if log.created_at else None, } for log in logs diff --git a/LinkSyncServer/api/endpoints/auth.py b/LinkSyncServer/api/endpoints/auth.py index 77a1efe..f0d4c5f 100644 --- a/LinkSyncServer/api/endpoints/auth.py +++ b/LinkSyncServer/api/endpoints/auth.py @@ -5,8 +5,9 @@ LinkSyncServer - Authentication Endpoints import hashlib import os import secrets +import uuid from datetime import datetime, timedelta -from typing import Optional +from typing import List, Optional import bcrypt import jwt @@ -178,6 +179,32 @@ async def logout(): return {"message": "Logged out successfully"} +@router.get("/api-keys", response_model=List[dict]) +async def list_api_keys( + current_user: dict = Depends(get_current_user), +): + db = get_session() + try: + user = db.query(User).filter(User.username == current_user["username"]).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() + return [ + { + "id": str(key.id), + "key_id": str(key.id), + "name": key.name, + "is_active": key.is_active, + "expires_at": key.expires_at.isoformat() if key.expires_at else None, + "created_at": key.created_at.isoformat() if key.created_at else None, + } + for key in api_keys + ] + finally: + db.close() + + @router.post("/api-key", response_model=ApiKeyResponse) async def create_api_key( name: str = "default", @@ -204,7 +231,7 @@ async def create_api_key( return { "api_key": raw_key, - "key_id": api_key.id, + "key_id": str(api_key.id), "name": api_key.name, "expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None, } @@ -223,14 +250,19 @@ async def get_api_key_info( if not user: raise HTTPException(status_code=404, detail="User not found") + try: + parsed_key_id = uuid.UUID(key_id) + except (ValueError, AttributeError): + raise HTTPException(status_code=404, detail="API key not found") + api_key = db.query(ApiKey).filter( - ApiKey.id == key_id, ApiKey.user_id == user.id + ApiKey.id == parsed_key_id, ApiKey.user_id == user.id ).first() if not api_key: raise HTTPException(status_code=404, detail="API key not found") return { - "key_id": api_key.id, + "key_id": str(api_key.id), "name": api_key.name, "is_active": api_key.is_active, "expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None, @@ -251,8 +283,13 @@ async def delete_api_key( if not user: raise HTTPException(status_code=404, detail="User not found") + try: + parsed_key_id = uuid.UUID(key_id) + except (ValueError, AttributeError): + raise HTTPException(status_code=404, detail="API key not found") + api_key = db.query(ApiKey).filter( - ApiKey.id == key_id, ApiKey.user_id == user.id + ApiKey.id == parsed_key_id, ApiKey.user_id == user.id ).first() if not api_key: raise HTTPException(status_code=404, detail="API key not found") diff --git a/LinkSyncServer/api/endpoints/collections.py b/LinkSyncServer/api/endpoints/collections.py index ae2898b..aa64037 100644 --- a/LinkSyncServer/api/endpoints/collections.py +++ b/LinkSyncServer/api/endpoints/collections.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy import and_, or_ -from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, get_session +from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, User, get_session from queries.executor import execute_query router = APIRouter(prefix="/api/collections", tags=["Collections"]) @@ -49,6 +49,13 @@ def get_current_user_id(request: Request) -> Optional[str]: 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, entity_type, entity_id, user_id, old_value=None, new_value=None): try: audit = AuditLog( @@ -73,12 +80,16 @@ async def list_collections( ): db = get_session() try: - user_id = get_current_user_id(request) if request else None + username = get_current_user_id(request) if request else None query = db.query(Collection) - if user_id: - query = query.filter( - or_(Collection.created_by == user_id, Collection.is_public == True) - ) + if username: + user = db.query(User).filter(User.username == username).first() + if user: + query = query.filter( + or_(Collection.created_by == user.id, Collection.is_public == True) + ) + else: + query = query.filter(Collection.is_public == True) else: query = query.filter(Collection.is_public == True) collections = query.order_by(Collection.created_at.desc()).offset(offset).limit(limit).all() @@ -91,14 +102,14 @@ async def list_collections( async def get_collection(collection_id: str): db = get_session() try: - collection = db.query(Collection).filter(Collection.id == collection_id).first() + collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") result = collection.to_dict() if collection.query_type == "static": links = ( db.query(CollectionBookmark) - .filter(CollectionBookmark.collection_id == collection_id) + .filter(CollectionBookmark.collection_id == parse_uuid(collection_id)) .all() ) result["link_ids"] = [lb.bookmark_id for lb in links] @@ -111,30 +122,40 @@ async def get_collection(collection_id: str): async def create_collection(data: CollectionCreate, request: Request): db = get_session() try: - user_id = get_current_user_id(request) - if not user_id: + username = get_current_user_id(request) + if not username: raise HTTPException(status_code=401, detail="Authentication required") + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + created_by_id = user.id + collection = Collection( - id=str(uuid.uuid4()), + id=uuid.uuid4(), name=data.name, description=data.description, query_type=data.query_type, query_expression=data.query_expression, is_public=data.is_public, - created_by=user_id, + created_by=created_by_id, ) db.add(collection) db.flush() if data.query_type == "static" and data.link_ids: for link_id in data.link_ids: - cb = CollectionBookmark(collection_id=collection.id, bookmark_id=link_id) + try: + lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id + except (ValueError, AttributeError): + continue + cb = CollectionBookmark(collection_id=collection.id, bookmark_id=lid) db.add(cb) db.commit() db.refresh(collection) - log_audit(db, "create", "Collection", collection.id, user_id, new_value=collection.to_dict()) + log_audit(db, "create", "Collection", collection.id, created_by_id, new_value=collection.to_dict()) return collection.to_dict() finally: db.close() @@ -144,7 +165,7 @@ async def create_collection(data: CollectionCreate, request: Request): async def update_collection(collection_id: str, data: CollectionUpdate, request: Request): db = get_session() try: - collection = db.query(Collection).filter(Collection.id == collection_id).first() + collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") @@ -155,8 +176,9 @@ async def update_collection(collection_id: str, data: CollectionUpdate, request: db.commit() db.refresh(collection) - user_id = get_current_user_id(request) - log_audit(db, "update", "Collection", collection_id, user_id, old_value=old_value, new_value=collection.to_dict()) + username = get_current_user_id(request) + user = db.query(User).filter(User.username == username).first() if username else None + log_audit(db, "update", "Collection", collection.id, user.id if user else None, old_value=old_value, new_value=collection.to_dict()) return collection.to_dict() finally: db.close() @@ -166,20 +188,21 @@ async def update_collection(collection_id: str, data: CollectionUpdate, request: async def delete_collection(collection_id: str, request: Request): db = get_session() try: - collection = db.query(Collection).filter(Collection.id == collection_id).first() + collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") old_value = collection.to_dict() if collection.query_type == "static": db.query(CollectionBookmark).filter( - CollectionBookmark.collection_id == collection_id + CollectionBookmark.collection_id == collection.id ).delete() db.delete(collection) db.commit() - user_id = get_current_user_id(request) - log_audit(db, "delete", "Collection", collection_id, user_id, old_value=old_value) - return {"message": "Collection deleted successfully", "deleted_id": collection_id} + username = get_current_user_id(request) + user = db.query(User).filter(User.username == username).first() if username else None + log_audit(db, "delete", "Collection", collection.id, user.id if user else None, old_value=old_value) + return {"message": "Collection deleted successfully", "deleted_id": str(collection.id)} finally: db.close() @@ -188,7 +211,7 @@ async def delete_collection(collection_id: str, request: Request): async def refresh_collection(collection_id: str): db = get_session() try: - collection = db.query(Collection).filter(Collection.id == collection_id).first() + collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.query_type != "dynamic": @@ -212,7 +235,10 @@ async def refresh_collection(collection_id: str): async def add_links_to_collection(collection_id: str, link_ids: List[str]): db = get_session() try: - collection = db.query(Collection).filter(Collection.id == collection_id).first() + parsed_cid = parse_uuid(collection_id) + if parsed_cid is None: + raise HTTPException(status_code=404, detail="Collection not found") + collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.query_type != "static": @@ -221,13 +247,17 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]): existing = { cb.bookmark_id for cb in db.query(CollectionBookmark) - .filter(CollectionBookmark.collection_id == collection_id) + .filter(CollectionBookmark.collection_id == parsed_cid) .all() } added = 0 for link_id in link_ids: - if link_id not in existing: - db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=link_id)) + try: + lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id + except (ValueError, AttributeError): + continue + if lid not in existing: + db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=lid)) added += 1 db.commit() @@ -240,15 +270,25 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]): async def remove_links_from_collection(collection_id: str, link_ids: List[str]): db = get_session() try: - collection = db.query(Collection).filter(Collection.id == collection_id).first() + parsed_cid = parse_uuid(collection_id) + if parsed_cid is None: + raise HTTPException(status_code=404, detail="Collection not found") + collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") + parsed_link_ids = [] + for link_id in link_ids: + try: + parsed_link_ids.append(uuid.UUID(link_id) if isinstance(link_id, str) else link_id) + except (ValueError, AttributeError): + continue + removed = ( db.query(CollectionBookmark) .filter( - CollectionBookmark.collection_id == collection_id, - CollectionBookmark.bookmark_id.in_(link_ids), + CollectionBookmark.collection_id == parsed_cid, + CollectionBookmark.bookmark_id.in_(parsed_link_ids), ) .delete(synchronize_session=False) ) diff --git a/LinkSyncServer/api/endpoints/links.py b/LinkSyncServer/api/endpoints/links.py index 0a4ec49..eaf860f 100644 --- a/LinkSyncServer/api/endpoints/links.py +++ b/LinkSyncServer/api/endpoints/links.py @@ -55,6 +55,13 @@ def get_current_user_id(request: Request) -> Optional[str]: 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( @@ -105,7 +112,10 @@ async def list_bookmarks( async def get_bookmark(bookmark_id: str): db = get_session() try: - bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() + 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() @@ -117,9 +127,14 @@ async def get_bookmark(bookmark_id: str): async def create_bookmark(data: BookmarkCreate, request: Request): db = get_session() try: - user_id = get_current_user_id(request) + 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=str(uuid.uuid4()), + id=uuid.uuid4(), url=data.url, title=data.title, description=data.description, @@ -144,7 +159,10 @@ async def create_bookmark(data: BookmarkCreate, request: Request): async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Request): db = get_session() try: - bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() + 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") @@ -155,8 +173,9 @@ async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Reque db.commit() db.refresh(bookmark) - user_id = get_current_user_id(request) - log_audit(db, "update", "Bookmark", bookmark_id, user_id, old_value=old_value, new_value=bookmark.to_dict()) + 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() @@ -166,16 +185,20 @@ async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Reque async def delete_bookmark(bookmark_id: str, request: Request): db = get_session() try: - bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() + 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() - user_id = get_current_user_id(request) - log_audit(db, "delete", "Bookmark", bookmark_id, user_id, old_value=old_value) - return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id} + 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() @@ -188,7 +211,10 @@ class TagList(BaseModel): async def add_tags(bookmark_id: str, data: TagList, request: Request): db = get_session() try: - bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() + 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") @@ -211,7 +237,10 @@ async def add_tags(bookmark_id: str, data: TagList, request: Request): async def remove_tags(bookmark_id: str, data: TagList, request: Request): db = get_session() try: - bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first() + 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") diff --git a/LinkSyncServer/api/endpoints/sync.py b/LinkSyncServer/api/endpoints/sync.py index 5d72446..d0d8b0c 100644 --- a/LinkSyncServer/api/endpoints/sync.py +++ b/LinkSyncServer/api/endpoints/sync.py @@ -36,11 +36,18 @@ class SyncResponse(BaseModel): synced_count: int +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 apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse: db = get_session() try: actions = [] - server_bookmarks = {b.id: b for b in db.query(Bookmark).all()} + server_bookmarks = {str(b.id): b for b in db.query(Bookmark).all()} for bm in browser_bookmarks: existing = server_bookmarks.get(bm.id) @@ -58,8 +65,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData existing.is_bookmarked = bm.is_bookmarked actions.append({"type": "update", "link_id": bm.id}) else: + try: + bm_uuid = uuid.UUID(bm.id) + except (ValueError, AttributeError): + bm_uuid = uuid.uuid4() new_bm = Bookmark( - id=bm.id, + id=bm_uuid, url=bm.url, title=bm.title, description=bm.description, @@ -86,8 +97,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData existing.is_bookmarked = bm.is_bookmarked actions.append({"type": "update", "link_id": bm.id}) else: + try: + bm_uuid = uuid.UUID(bm.id) + except (ValueError, AttributeError): + bm_uuid = uuid.uuid4() new_bm = Bookmark( - id=bm.id, + id=bm_uuid, url=bm.url, title=bm.title, description=bm.description, @@ -103,8 +118,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData elif sync_config.mode == "server-authoritative": if not existing: + try: + bm_uuid = uuid.UUID(bm.id) + except (ValueError, AttributeError): + bm_uuid = uuid.uuid4() new_bm = Bookmark( - id=bm.id, + id=bm_uuid, url=bm.url, title=bm.title, description=bm.description, @@ -122,8 +141,10 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData browser_ids = {bm.id for bm in browser_bookmarks} for server_id in server_bookmarks: if server_id not in browser_ids: - db.query(Bookmark).filter(Bookmark.id == server_id).delete() - actions.append({"type": "delete", "link_id": server_id}) + parsed_id = parse_uuid(server_id) + if parsed_id: + db.query(Bookmark).filter(Bookmark.id == parsed_id).delete() + actions.append({"type": "delete", "link_id": server_id}) db.commit() return SyncResponse(actions=actions, synced_count=len(actions)) @@ -155,7 +176,10 @@ async def get_collection(collection_id: str): db = get_session() try: from models.base import Collection - collection = db.query(Collection).filter(Collection.id == collection_id).first() + parsed_id = parse_uuid(collection_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="Collection not found") + collection = db.query(Collection).filter(Collection.id == parsed_id).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") return collection.to_dict() @@ -168,7 +192,10 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]): db = get_session() try: from models.base import Collection, CollectionBookmark - collection = db.query(Collection).filter(Collection.id == collection_id).first() + parsed_cid = parse_uuid(collection_id) + if parsed_cid is None: + raise HTTPException(status_code=404, detail="Collection not found") + collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.query_type != "static": @@ -176,16 +203,19 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]): added = 0 for bid in bookmark_ids: + parsed_bid = parse_uuid(bid) + if parsed_bid is None: + continue existing = ( db.query(CollectionBookmark) .filter( - CollectionBookmark.collection_id == collection_id, - CollectionBookmark.bookmark_id == bid, + CollectionBookmark.collection_id == parsed_cid, + CollectionBookmark.bookmark_id == parsed_bid, ) .first() ) if not existing: - db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=bid)) + db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=parsed_bid)) added += 1 db.commit() @@ -199,12 +229,15 @@ async def delete_collection(collection_id: str): db = get_session() try: from models.base import Collection, CollectionBookmark - collection = db.query(Collection).filter(Collection.id == collection_id).first() + parsed_cid = parse_uuid(collection_id) + if parsed_cid is None: + raise HTTPException(status_code=404, detail="Collection not found") + collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") db.query(CollectionBookmark).filter( - CollectionBookmark.collection_id == collection_id + CollectionBookmark.collection_id == parsed_cid ).delete() db.delete(collection) db.commit() diff --git a/LinkSyncServer/api/endpoints/tags.py b/LinkSyncServer/api/endpoints/tags.py index d4fac00..f0f7403 100644 --- a/LinkSyncServer/api/endpoints/tags.py +++ b/LinkSyncServer/api/endpoints/tags.py @@ -28,6 +28,13 @@ class TagUpdate(BaseModel): description: Optional[str] = Field(None, max_length=500) +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 + + @router.get("/", response_model=List[dict]) async def list_tags( page: int = Query(1, ge=1), @@ -58,7 +65,10 @@ async def tag_count(): async def get_tag(tag_id: str): db = get_session() try: - tag = db.query(Tag).filter(Tag.id == tag_id).first() + parsed_id = parse_uuid(tag_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="Tag not found") + tag = db.query(Tag).filter(Tag.id == parsed_id).first() if not tag: raise HTTPException(status_code=404, detail="Tag not found") return tag.to_dict() @@ -86,7 +96,10 @@ async def get_tag_links( ): db = get_session() try: - tag = db.query(Tag).filter(Tag.id == tag_id).first() + parsed_id = parse_uuid(tag_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="Tag not found") + tag = db.query(Tag).filter(Tag.id == parsed_id).first() if not tag: raise HTTPException(status_code=404, detail="Tag not found") @@ -112,7 +125,7 @@ async def create_tag(data: TagCreate): raise HTTPException(status_code=400, detail="Tag already exists") tag = Tag( - id=str(uuid.uuid4()), + id=uuid.uuid4(), name=data.name, color=data.color, description=data.description, @@ -129,7 +142,10 @@ async def create_tag(data: TagCreate): async def update_tag(tag_id: str, data: TagUpdate): db = get_session() try: - tag = db.query(Tag).filter(Tag.id == tag_id).first() + parsed_id = parse_uuid(tag_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="Tag not found") + tag = db.query(Tag).filter(Tag.id == parsed_id).first() if not tag: raise HTTPException(status_code=404, detail="Tag not found") @@ -148,7 +164,10 @@ async def update_tag(tag_id: str, data: TagUpdate): async def delete_tag(tag_id: str): db = get_session() try: - tag = db.query(Tag).filter(Tag.id == tag_id).first() + parsed_id = parse_uuid(tag_id) + if parsed_id is None: + raise HTTPException(status_code=404, detail="Tag not found") + tag = db.query(Tag).filter(Tag.id == parsed_id).first() if not tag: raise HTTPException(status_code=404, detail="Tag not found") diff --git a/LinkSyncServer/app.py b/LinkSyncServer/app.py index 2b0b39c..c002ef9 100644 --- a/LinkSyncServer/app.py +++ b/LinkSyncServer/app.py @@ -3,10 +3,11 @@ LinkSyncServer - Main Application """ import os -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse from contextlib import asynccontextmanager from api.routes import router as api_router @@ -51,5 +52,35 @@ def health(): @app.get("/") -def index(request): - return templates.TemplateResponse("index.html", {"request": request}) +def index(): + return RedirectResponse(url="/login") + + +@app.get("/login") +def login_page(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + + +@app.get("/dashboard") +def dashboard(request: Request): + return templates.TemplateResponse("dashboard.html", {"request": request}) + + +@app.get("/links") +def links_page(request: Request): + return templates.TemplateResponse("links.html", {"request": request}) + + +@app.get("/collections") +def collections_page(request: Request): + return templates.TemplateResponse("collections.html", {"request": request}) + + +@app.get("/api-keys") +def apikeys_page(request: Request): + return templates.TemplateResponse("apikeys.html", {"request": request}) + + +@app.get("/admin") +def admin_page(request: Request): + return templates.TemplateResponse("admin.html", {"request": request}) diff --git a/LinkSyncServer/config/schema.sql b/LinkSyncServer/config/schema.sql index 56661fb..a4a5c60 100644 --- a/LinkSyncServer/config/schema.sql +++ b/LinkSyncServer/config/schema.sql @@ -23,7 +23,8 @@ CREATE TABLE api_keys ( name VARCHAR(100), expires_at TIMESTAMP WITH TIME ZONE, is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Tags table @@ -92,7 +93,8 @@ CREATE TABLE audit_log ( old_value JSONB, new_value JSONB, ip_address INET, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Create audit log index diff --git a/LinkSyncServer/docker-compose.yml b/LinkSyncServer/docker-compose.yml index 02cc3de..0badb85 100644 --- a/LinkSyncServer/docker-compose.yml +++ b/LinkSyncServer/docker-compose.yml @@ -6,7 +6,7 @@ services: ports: - "5000:5000" environment: - - DATABASE_URL=postgresql://linksync:${DB_PASSWORD}@db:5432/linksync + - DATABASE_URL=postgresql://linksync:${DB_PASSWORD:-password}@db:5432/linksync - SECRET_KEY=${SECRET_KEY:-$(openssl rand -base64 32)} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} diff --git a/LinkSyncServer/models/base.py b/LinkSyncServer/models/base.py index cdf4aed..6a7b7d7 100644 --- a/LinkSyncServer/models/base.py +++ b/LinkSyncServer/models/base.py @@ -18,6 +18,7 @@ from sqlalchemy import ( JSON, text, ) +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, sessionmaker from sqlalchemy.sql import func @@ -58,7 +59,7 @@ class User(Base, TimestampMixin): """User model for authentication.""" __tablename__ = "users" - id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) username = Column(String(100), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) @@ -72,7 +73,7 @@ class User(Base, TimestampMixin): def to_dict(self): return { - "id": self.id, + "id": str(self.id), "username": self.username, "email": self.email, "role": self.role, @@ -86,8 +87,8 @@ class ApiKey(Base, TimestampMixin): """API Key for authentication.""" __tablename__ = "api_keys" - id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) key_hash = Column(String(255), nullable=False, unique=True) name = Column(String(100)) expires_at = Column(DateTime) @@ -100,14 +101,14 @@ class Tag(Base, TimestampMixin): """Tag model for bookmarks.""" __tablename__ = "tags" - id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(100), unique=True, nullable=False, index=True) color = Column(String(7)) description = Column(Text) def to_dict(self): return { - "id": self.id, + "id": str(self.id), "name": self.name, "color": self.color, "description": self.description, @@ -120,7 +121,7 @@ class Bookmark(Base, TimestampMixin): """Bookmark/Link model with Firefox-compatible fields.""" __tablename__ = "links" - id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) url = Column(String(2048), nullable=False, index=True) title = Column(String(255), nullable=False) description = Column(Text) @@ -130,8 +131,8 @@ class Bookmark(Base, TimestampMixin): path = Column(String(512), nullable=True) visit_count = Column(Integer, default=0) is_bookmarked = Column(Boolean, default=False) - source_set_id = Column(String(36), ForeignKey("links.id")) - user_id = Column(String(36), ForeignKey("users.id"), nullable=True) + source_set_id = Column(UUID(as_uuid=True), ForeignKey("links.id")) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) user = relationship("User", back_populates="bookmarks") source_set = relationship("Bookmark", remote_side=[id]) @@ -139,7 +140,7 @@ class Bookmark(Base, TimestampMixin): def to_dict(self): return { - "id": self.id, + "id": str(self.id), "url": self.url, "title": self.title, "description": self.description, @@ -149,8 +150,8 @@ class Bookmark(Base, TimestampMixin): "path": self.path, "visit_count": self.visit_count, "is_bookmarked": self.is_bookmarked, - "source_set_id": self.source_set_id, - "user_id": self.user_id, + "source_set_id": str(self.source_set_id) if self.source_set_id else None, + "user_id": str(self.user_id) if self.user_id else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } @@ -160,26 +161,26 @@ class Collection(Base, TimestampMixin): """Collection model for bookmark sets.""" __tablename__ = "collections" - id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(200), nullable=False) description = Column(Text) query_type = Column(String(20), nullable=False) query_expression = Column(JSON) is_public = Column(Boolean, default=False) - created_by = Column(String(36), ForeignKey("users.id"), nullable=False) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) user = relationship("User", back_populates="collections") collection_bookmarks = relationship("CollectionBookmark", back_populates="collection") def to_dict(self): return { - "id": self.id, + "id": str(self.id), "name": self.name, "description": self.description, "query_type": self.query_type, "query_expression": self.query_expression, "is_public": self.is_public, - "created_by": self.created_by, + "created_by": str(self.created_by), "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } @@ -187,10 +188,10 @@ class Collection(Base, TimestampMixin): class CollectionBookmark(Base, TimestampMixin): """Junction table for static collections.""" - __tablename__ = "collection_bookmarks" + __tablename__ = "collection_links" - collection_id = Column(String(36), ForeignKey("collections.id"), primary_key=True) - bookmark_id = Column(String(36), ForeignKey("links.id"), primary_key=True) + collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.id"), primary_key=True) + bookmark_id = Column(UUID(as_uuid=True), ForeignKey("links.id"), primary_key=True) collection = relationship("Collection", back_populates="collection_bookmarks") bookmark = relationship("Bookmark", back_populates="collection_bookmarks") @@ -200,11 +201,11 @@ class AuditLog(Base, TimestampMixin): """Audit log for tracking changes.""" __tablename__ = "audit_log" - id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) action = Column(String(100), nullable=False) entity_type = Column(String(50), nullable=False) - entity_id = Column(String(36)) + entity_id = Column(UUID(as_uuid=True)) old_value = Column(JSON) new_value = Column(JSON) ip_address = Column(String(45)) diff --git a/LinkSyncServer/requirements.txt b/LinkSyncServer/requirements.txt index 8725f0d..da6abc2 100644 --- a/LinkSyncServer/requirements.txt +++ b/LinkSyncServer/requirements.txt @@ -9,6 +9,7 @@ psycopg2-binary==2.9.9 alembic==1.13.1 # Authentication +PyJWT==2.8.0 python-jose[cryptography]==3.3.0 pycryptodome==3.19.0 bcrypt==4.1.2 diff --git a/LinkSyncServer/static/css/main.css b/LinkSyncServer/static/css/main.css index 08fb630..3dd9397 100644 --- a/LinkSyncServer/static/css/main.css +++ b/LinkSyncServer/static/css/main.css @@ -9,8 +9,10 @@ --border: #e2e8f0; --success: #22c55e; --error: #ef4444; + --warning: #f59e0b; --radius: 8px; --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15); } * { @@ -63,6 +65,598 @@ body { color: var(--primary); } +.nav-user { + display: flex; + align-items: center; + gap: 1rem; +} + +.nav-user span { + font-weight: 500; + color: var(--text); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.625rem 1.25rem; + border-radius: var(--radius); + text-decoration: none; + font-weight: 500; + font-size: 0.875rem; + border: none; + cursor: pointer; + transition: background 0.2s, opacity 0.2s; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--secondary); + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background: #475569; +} + +.btn-danger { + background: var(--error); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +.btn-outline:hover { + background: var(--bg); +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; +} + +.btn-full { + width: 100%; +} + +.btn-icon { + padding: 0.375rem; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-muted); + border-radius: 4px; +} + +.btn-icon:hover { + background: var(--bg); + color: var(--text); +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.page-header h1 { + font-size: 1.75rem; +} + +.search-bar { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.search-bar input { + flex: 1; + padding: 0.625rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.375rem; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.625rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + font-family: inherit; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.form-group textarea { + resize: vertical; +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.checkbox-group input[type="checkbox"] { + width: auto; +} + +.form-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1.5rem; +} + +.error-message { + background: #fef2f2; + color: var(--error); + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.success-message { + background: #f0fdf4; + color: var(--success); + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.loading { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.empty-state { + text-align: center; + padding: 3rem; + color: var(--text-muted); +} + +.empty-state p { + margin-bottom: 1rem; +} + +/* Login Page */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.login-container { + width: 100%; + max-width: 400px; + padding: 1rem; +} + +.login-card { + background: var(--surface); + padding: 2.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); +} + +.login-card h1 { + text-align: center; + color: var(--primary); + margin-bottom: 0.5rem; +} + +.login-subtitle { + text-align: center; + color: var(--text-muted); + margin-bottom: 2rem; +} + +/* Dashboard */ +.dashboard-header { + margin-bottom: 2rem; +} + +.dashboard-header h1 { + font-size: 1.75rem; + margin-bottom: 0.25rem; +} + +.welcome-text { + color: var(--text-muted); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 2.5rem; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + text-align: center; + box-shadow: var(--shadow); +} + +.stat-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--primary); +} + +.stat-label { + color: var(--text-muted); + font-size: 0.875rem; + margin: 0.25rem 0 0.75rem; +} + +.stat-link { + color: var(--primary); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; +} + +.stat-link:hover { + text-decoration: underline; +} + +.quick-actions h2, +.admin-quick h2 { + font-size: 1.25rem; + margin-bottom: 1rem; +} + +.action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.action-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + text-align: center; + text-decoration: none; + transition: box-shadow 0.2s, border-color 0.2s; +} + +.action-card:hover { + box-shadow: var(--shadow-lg); + border-color: var(--primary); +} + +.action-icon { + display: block; + font-size: 1.5rem; + margin-bottom: 0.5rem; +} + +.action-label { + color: var(--text); + font-weight: 500; + font-size: 0.875rem; +} + +/* Tables */ +.data-table { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); +} + +.data-table table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.data-table th { + background: var(--bg); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.data-table td { + font-size: 0.875rem; +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tr:hover td { + background: #f8fafc; +} + +.data-table .actions { + display: flex; + gap: 0.5rem; +} + +.data-table .truncate { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Links page */ +.links-table .link-url { + color: var(--primary); + text-decoration: none; +} + +.links-table .link-url:hover { + text-decoration: underline; +} + +.links-table .tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.links-table .tag { + background: #e0e7ff; + color: #3730a3; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; +} + +/* Collections grid */ +.collections-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.collection-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: var(--shadow); +} + +.collection-card h3 { + margin-bottom: 0.5rem; + color: var(--text); +} + +.collection-card p { + color: var(--text-muted); + font-size: 0.875rem; + margin-bottom: 0.75rem; +} + +.collection-card .meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.collection-card .badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.badge-static { + background: #dbeafe; + color: #1e40af; +} + +.badge-dynamic { + background: #fef3c7; + color: #92400e; +} + +.badge-public { + background: #dcfce7; + color: #166534; +} + +.badge-private { + background: #f1f5f9; + color: #475569; +} + +.collection-card .actions { + display: flex; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +/* API Keys */ +.api-keys-table .key-value { + font-family: "Fira Code", "Cascadia Code", monospace; + font-size: 0.75rem; + color: var(--text-muted); +} + +.api-keys-table .status-active { + color: var(--success); + font-weight: 500; +} + +.api-keys-table .status-inactive { + color: var(--error); + font-weight: 500; +} + +.key-result { + margin: 1rem 0; +} + +.key-display { + display: flex; + gap: 0.5rem; + align-items: center; + background: var(--bg); + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin: 0.75rem 0; +} + +.key-display code { + flex: 1; + font-family: "Fira Code", "Cascadia Code", monospace; + font-size: 0.875rem; + word-break: break-all; +} + +/* Users table */ +.users-table .role-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.role-admin { + background: #fef3c7; + color: #92400e; +} + +.role-user { + background: #e0e7ff; + color: #3730a3; +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.modal-content { + position: relative; + background: var(--surface); + border-radius: var(--radius); + padding: 1.5rem; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); +} + +.modal-content.modal-sm { + max-width: 400px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.25rem; +} + +.modal-header h2 { + font-size: 1.25rem; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-muted); + padding: 0.25rem; + line-height: 1; +} + +.modal-close:hover { + color: var(--text); +} + +/* Hero (legacy) */ .hero { text-align: center; padding: 4rem 1rem; @@ -85,33 +679,6 @@ body { justify-content: center; } -.btn { - display: inline-block; - padding: 0.75rem 1.5rem; - border-radius: var(--radius); - text-decoration: none; - font-weight: 500; - transition: background 0.2s; -} - -.btn-primary { - background: var(--primary); - color: white; -} - -.btn-primary:hover { - background: var(--primary-hover); -} - -.btn-secondary { - background: var(--secondary); - color: white; -} - -.btn-secondary:hover { - background: #475569; -} - .section { margin: 3rem 0; } @@ -207,4 +774,15 @@ body { .hero-actions { flex-direction: column; } + .page-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + .data-table { + overflow-x: auto; + } + .data-table table { + min-width: 600px; + } } diff --git a/LinkSyncServer/static/js/admin-page.js b/LinkSyncServer/static/js/admin-page.js new file mode 100644 index 0000000..238d5ed --- /dev/null +++ b/LinkSyncServer/static/js/admin-page.js @@ -0,0 +1,180 @@ +document.addEventListener('DOMContentLoaded', function() { + const usersList = document.getElementById('users-list'); + const modal = document.getElementById('user-modal'); + const deleteModal = document.getElementById('delete-user-modal'); + const form = document.getElementById('user-form'); + let deleteTargetId = null; + + async function loadUsers() { + try { + const users = await LinkSync.getUsers(); + renderUsers(Array.isArray(users) ? users : []); + } catch (err) { + usersList.innerHTML = `

Failed to load users: ${err.message}

`; + } + } + + function renderUsers(users) { + if (!users || users.length === 0) { + usersList.innerHTML = '

No users found.

'; + return; + } + + usersList.innerHTML = ` +
+ + + + + + + + + + + + + ${users.map(user => ` + + + + + + + + + `).join('')} + +
UsernameEmailRoleStatusCreatedActions
${escapeHtml(user.username)}${escapeHtml(user.email)}${user.role}${user.is_active ? 'Active' : 'Inactive'}${formatDate(user.created_at)} + + +
+
+ `; + + usersList.querySelectorAll('[data-action="edit"]').forEach(btn => { + btn.addEventListener('click', () => editUser(btn.dataset.id)); + }); + usersList.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', () => confirmDelete(btn.dataset.id)); + }); + } + + function openUserModal(user = null) { + document.getElementById('user-modal-title').textContent = user ? 'Edit User' : 'Create User'; + document.getElementById('user-id').value = user ? user.id : ''; + document.getElementById('user-username').value = user ? user.username : ''; + document.getElementById('user-username').disabled = !!user; + document.getElementById('user-email').value = user ? user.email : ''; + document.getElementById('user-password').value = ''; + document.getElementById('user-password').required = !user; + document.getElementById('user-password').placeholder = user ? 'Leave blank to keep current' : ''; + document.getElementById('user-role').value = user ? user.role : 'user'; + document.getElementById('user-active').checked = user ? user.is_active : true; + modal.style.display = 'flex'; + } + + async function editUser(id) { + try { + const users = await LinkSync.getUsers(); + const user = (Array.isArray(users) ? users : []).find(u => u.id === id); + if (user) openUserModal(user); + } catch (err) { + alert('Failed to load user details'); + } + } + + function confirmDelete(id) { + deleteTargetId = id; + deleteModal.style.display = 'flex'; + } + + function closeModal() { + modal.style.display = 'none'; + form.reset(); + document.getElementById('user-username').disabled = false; + } + + function closeDeleteModal() { + deleteModal.style.display = 'none'; + deleteTargetId = null; + } + + document.getElementById('new-user-btn').addEventListener('click', () => openUserModal()); + document.getElementById('user-modal-close').addEventListener('click', closeModal); + document.getElementById('user-cancel-btn').addEventListener('click', closeModal); + document.getElementById('delete-user-cancel-btn').addEventListener('click', closeDeleteModal); + + modal.querySelector('.modal-overlay').addEventListener('click', closeModal); + deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + const saveBtn = document.getElementById('user-save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + const id = document.getElementById('user-id').value; + const data = { + username: document.getElementById('user-username').value, + email: document.getElementById('user-email').value, + role: document.getElementById('user-role').value, + is_active: document.getElementById('user-active').checked, + }; + + const password = document.getElementById('user-password').value; + if (password) data.password = password; + + try { + if (id) { + await LinkSync.updateUser(id, data); + } else { + if (!password) { + alert('Password is required for new users'); + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + return; + } + await LinkSync.createUser(data); + } + closeModal(); + loadUsers(); + } catch (err) { + alert('Failed to save user: ' + err.message); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + } + }); + + document.getElementById('confirm-delete-user-btn').addEventListener('click', async function() { + if (!deleteTargetId) return; + try { + await LinkSync.deleteUser(deleteTargetId); + closeDeleteModal(); + loadUsers(); + } catch (err) { + alert('Failed to delete user: ' + err.message); + } + }); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('action') === 'new-user') { + openUserModal(); + } + + loadUsers(); + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function formatDate(dateStr) { + if (!dateStr) return '-'; + const d = new Date(dateStr); + return d.toLocaleDateString(); + } +}); diff --git a/LinkSyncServer/static/js/apikeys-page.js b/LinkSyncServer/static/js/apikeys-page.js new file mode 100644 index 0000000..6bcbb9f --- /dev/null +++ b/LinkSyncServer/static/js/apikeys-page.js @@ -0,0 +1,156 @@ +document.addEventListener('DOMContentLoaded', function() { + const keysList = document.getElementById('api-keys-list'); + const keyModal = document.getElementById('key-modal'); + const keyResultModal = document.getElementById('key-result-modal'); + const deleteModal = document.getElementById('delete-key-modal'); + const keyForm = document.getElementById('key-form'); + let deleteTargetId = null; + + async function loadKeys() { + try { + const keys = await LinkSync.getApiKeys(); + renderKeys(Array.isArray(keys) ? keys : []); + } catch (err) { + keysList.innerHTML = `

Failed to load API keys: ${err.message}

`; + } + } + + function renderKeys(keys) { + if (!keys || keys.length === 0) { + keysList.innerHTML = '

No API keys found.

'; + document.getElementById('empty-add-key-btn').addEventListener('click', openKeyModal); + return; + } + + keysList.innerHTML = ` +
+ + + + + + + + + + + + + ${keys.map(key => ` + + + + + + + + + `).join('')} + +
NameKey IDStatusCreatedExpiresActions
${escapeHtml(key.name)}${escapeHtml(key.key_id || key.id)}${key.is_active ? 'Active' : 'Inactive'}${formatDate(key.created_at)}${key.expires_at ? formatDate(key.expires_at) : 'Never'} + +
+
+ `; + + keysList.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', () => confirmDeleteKey(btn.dataset.id)); + }); + } + + function openKeyModal() { + document.getElementById('key-name').value = ''; + keyModal.style.display = 'flex'; + } + + function closeKeyModal() { + keyModal.style.display = 'none'; + keyForm.reset(); + } + + function closeKeyResultModal() { + keyResultModal.style.display = 'none'; + } + + function closeDeleteModal() { + deleteModal.style.display = 'none'; + deleteTargetId = null; + } + + document.getElementById('new-key-btn').addEventListener('click', openKeyModal); + document.getElementById('key-modal-close').addEventListener('click', closeKeyModal); + document.getElementById('key-cancel-btn').addEventListener('click', closeKeyModal); + document.getElementById('key-result-close').addEventListener('click', closeKeyResultModal); + document.getElementById('key-done-btn').addEventListener('click', closeKeyResultModal); + document.getElementById('delete-key-cancel-btn').addEventListener('click', closeDeleteModal); + + keyModal.querySelector('.modal-overlay').addEventListener('click', closeKeyModal); + keyResultModal.querySelector('.modal-overlay').addEventListener('click', closeKeyResultModal); + deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); + + keyForm.addEventListener('submit', async function(e) { + e.preventDefault(); + const saveBtn = document.getElementById('key-save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Creating...'; + + const name = document.getElementById('key-name').value; + + try { + const result = await LinkSync.createApiKey(name); + closeKeyModal(); + document.getElementById('new-key-value').textContent = result.api_key; + keyResultModal.style.display = 'flex'; + loadKeys(); + } catch (err) { + alert('Failed to create API key: ' + err.message); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Create'; + } + }); + + document.getElementById('copy-key-btn').addEventListener('click', function() { + const key = document.getElementById('new-key-value').textContent; + navigator.clipboard.writeText(key).then(() => { + this.textContent = 'Copied!'; + setTimeout(() => { this.textContent = 'Copy'; }, 2000); + }); + }); + + function confirmDeleteKey(id) { + deleteTargetId = id; + deleteModal.style.display = 'flex'; + } + + document.getElementById('confirm-delete-key-btn').addEventListener('click', async function() { + if (!deleteTargetId) return; + try { + await LinkSync.deleteApiKey(deleteTargetId); + closeDeleteModal(); + loadKeys(); + } catch (err) { + alert('Failed to delete API key: ' + err.message); + } + }); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('action') === 'new') { + openKeyModal(); + } + + loadKeys(); + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function formatDate(dateStr) { + if (!dateStr) return '-'; + const d = new Date(dateStr); + return d.toLocaleDateString(); + } +}); diff --git a/LinkSyncServer/static/js/collections-page.js b/LinkSyncServer/static/js/collections-page.js new file mode 100644 index 0000000..164bad1 --- /dev/null +++ b/LinkSyncServer/static/js/collections-page.js @@ -0,0 +1,144 @@ +document.addEventListener('DOMContentLoaded', function() { + const collectionsList = document.getElementById('collections-list'); + const modal = document.getElementById('collection-modal'); + const deleteModal = document.getElementById('delete-collection-modal'); + const form = document.getElementById('collection-form'); + let deleteTargetId = null; + + async function loadCollections() { + try { + const collections = await LinkSync.getCollections(); + renderCollections(Array.isArray(collections) ? collections : []); + } catch (err) { + collectionsList.innerHTML = `

Failed to load collections: ${err.message}

`; + } + } + + function renderCollections(collections) { + if (!collections || collections.length === 0) { + collectionsList.innerHTML = '

No collections found.

'; + document.getElementById('empty-add-col-btn').addEventListener('click', openCollectionModal); + return; + } + + collectionsList.innerHTML = collections.map(col => ` +
+

${escapeHtml(col.name)}

+

${escapeHtml(col.description || 'No description')}

+
+ ${col.query_type} + ${col.is_public ? 'Public' : 'Private'} +
+
+ + +
+
+ `).join(''); + + collectionsList.querySelectorAll('[data-action="edit"]').forEach(btn => { + btn.addEventListener('click', () => editCollection(btn.dataset.id)); + }); + collectionsList.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', () => confirmDeleteCollection(btn.dataset.id)); + }); + } + + function openCollectionModal(col = null) { + document.getElementById('collection-modal-title').textContent = col ? 'Edit Collection' : 'Create Collection'; + document.getElementById('collection-id').value = col ? col.id : ''; + document.getElementById('collection-name').value = col ? col.name : ''; + document.getElementById('collection-description').value = col ? (col.description || '') : ''; + document.getElementById('collection-type').value = col ? col.query_type : 'static'; + document.getElementById('collection-public').checked = col ? col.is_public : false; + modal.style.display = 'flex'; + } + + async function editCollection(id) { + try { + const collections = await LinkSync.getCollections(); + const col = (Array.isArray(collections) ? collections : []).find(c => c.id === id); + if (col) openCollectionModal(col); + } catch (err) { + alert('Failed to load collection details'); + } + } + + function confirmDeleteCollection(id) { + deleteTargetId = id; + deleteModal.style.display = 'flex'; + } + + function closeModal() { + modal.style.display = 'none'; + form.reset(); + } + + function closeDeleteModal() { + deleteModal.style.display = 'none'; + deleteTargetId = null; + } + + document.getElementById('new-collection-btn').addEventListener('click', () => openCollectionModal()); + document.getElementById('collection-modal-close').addEventListener('click', closeModal); + document.getElementById('collection-cancel-btn').addEventListener('click', closeModal); + document.getElementById('delete-collection-cancel-btn').addEventListener('click', closeDeleteModal); + + modal.querySelector('.modal-overlay').addEventListener('click', closeModal); + deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + const saveBtn = document.getElementById('collection-save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + const id = document.getElementById('collection-id').value; + const data = { + name: document.getElementById('collection-name').value, + description: document.getElementById('collection-description').value || null, + query_type: document.getElementById('collection-type').value, + is_public: document.getElementById('collection-public').checked, + }; + + try { + if (id) { + await LinkSync.updateCollection(id, data); + } else { + await LinkSync.createCollection(data); + } + closeModal(); + loadCollections(); + } catch (err) { + alert('Failed to save collection: ' + err.message); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + } + }); + + document.getElementById('confirm-delete-collection-btn').addEventListener('click', async function() { + if (!deleteTargetId) return; + try { + await LinkSync.deleteCollection(deleteTargetId); + closeDeleteModal(); + loadCollections(); + } catch (err) { + alert('Failed to delete collection: ' + err.message); + } + }); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('action') === 'new') { + openCollectionModal(); + } + + loadCollections(); + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } +}); diff --git a/LinkSyncServer/static/js/dashboard.js b/LinkSyncServer/static/js/dashboard.js new file mode 100644 index 0000000..c7aae8d --- /dev/null +++ b/LinkSyncServer/static/js/dashboard.js @@ -0,0 +1,27 @@ +document.addEventListener('DOMContentLoaded', async function() { + const user = JSON.parse(localStorage.getItem('user') || 'null'); + if (!user) { + window.location.href = '/login'; + return; + } + + document.getElementById('current-user').textContent = user.username; + + if (user.role === 'admin') { + document.getElementById('admin-section').style.display = ''; + } + + try { + const [links, collections, keys] = await Promise.all([ + LinkSync.getLinks({ limit: 1 }), + LinkSync.getCollections(), + LinkSync.getApiKeys(), + ]); + + document.getElementById('link-count').textContent = Array.isArray(links) ? links.length : 0; + document.getElementById('collection-count').textContent = Array.isArray(collections) ? collections.length : 0; + document.getElementById('api-key-count').textContent = Array.isArray(keys) ? keys.length : 0; + } catch (err) { + console.error('Failed to load stats:', err); + } +}); diff --git a/LinkSyncServer/static/js/links-page.js b/LinkSyncServer/static/js/links-page.js new file mode 100644 index 0000000..f57d71f --- /dev/null +++ b/LinkSyncServer/static/js/links-page.js @@ -0,0 +1,178 @@ +document.addEventListener('DOMContentLoaded', function() { + const linksList = document.getElementById('links-list'); + const modal = document.getElementById('link-modal'); + const deleteModal = document.getElementById('delete-modal'); + const form = document.getElementById('link-form'); + const searchInput = document.getElementById('search-input'); + let deleteTargetId = null; + + async function loadLinks(search = '') { + try { + const links = await LinkSync.getLinks(search ? { search } : {}); + renderLinks(Array.isArray(links) ? links : []); + } catch (err) { + linksList.innerHTML = `

Failed to load links: ${err.message}

`; + } + } + + function renderLinks(links) { + if (!links || links.length === 0) { + linksList.innerHTML = '

No links found.

'; + document.getElementById('empty-add-btn').addEventListener('click', openModal); + return; + } + + linksList.innerHTML = ` +
+ + + + + + + + + + + + ${links.map(link => ` + + + + + + + + `).join('')} + +
TitleURLTagsCreatedActions
${escapeHtml(link.title)}${escapeHtml(link.url)}
${(link.tags || []).map(t => `${escapeHtml(t)}`).join('')}
${formatDate(link.created_at)} + + +
+
+ `; + + linksList.querySelectorAll('[data-action="edit"]').forEach(btn => { + btn.addEventListener('click', () => editLink(btn.dataset.id)); + }); + linksList.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', () => confirmDelete(btn.dataset.id)); + }); + } + + function openModal(link = null) { + document.getElementById('modal-title').textContent = link ? 'Edit Link' : 'Add Link'; + document.getElementById('link-id').value = link ? link.id : ''; + document.getElementById('link-url').value = link ? link.url : ''; + document.getElementById('link-title').value = link ? link.title : ''; + document.getElementById('link-description').value = link ? (link.description || '') : ''; + document.getElementById('link-notes').value = link ? (link.notes || '') : ''; + document.getElementById('link-tags').value = link ? (link.tags || []).join(', ') : ''; + document.getElementById('link-favicon').value = link ? (link.favicon_url || '') : ''; + document.getElementById('link-path').value = link ? (link.path || '') : ''; + modal.style.display = 'flex'; + } + + async function editLink(id) { + try { + const links = await LinkSync.getLinks(); + const link = (Array.isArray(links) ? links : []).find(l => l.id === id); + if (link) openModal(link); + } catch (err) { + alert('Failed to load link details'); + } + } + + function confirmDelete(id) { + deleteTargetId = id; + deleteModal.style.display = 'flex'; + } + + function closeModal() { + modal.style.display = 'none'; + form.reset(); + } + + function closeDeleteModal() { + deleteModal.style.display = 'none'; + deleteTargetId = null; + } + + document.getElementById('new-link-btn').addEventListener('click', () => openModal()); + document.getElementById('modal-close').addEventListener('click', closeModal); + document.getElementById('cancel-btn').addEventListener('click', closeModal); + document.getElementById('delete-cancel-btn').addEventListener('click', closeDeleteModal); + + modal.querySelector('.modal-overlay').addEventListener('click', closeModal); + deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal); + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + const saveBtn = document.getElementById('save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + const id = document.getElementById('link-id').value; + const tagsRaw = document.getElementById('link-tags').value; + const data = { + url: document.getElementById('link-url').value, + title: document.getElementById('link-title').value, + description: document.getElementById('link-description').value || null, + notes: document.getElementById('link-notes').value || null, + tags: tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t) : [], + favicon_url: document.getElementById('link-favicon').value || null, + path: document.getElementById('link-path').value || null, + }; + + try { + if (id) { + await LinkSync.updateLink(id, data); + } else { + await LinkSync.createLink(data); + } + closeModal(); + loadLinks(searchInput.value); + } catch (err) { + alert('Failed to save link: ' + err.message); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + } + }); + + document.getElementById('confirm-delete-btn').addEventListener('click', async function() { + if (!deleteTargetId) return; + try { + await LinkSync.deleteLink(deleteTargetId); + closeDeleteModal(); + loadLinks(searchInput.value); + } catch (err) { + alert('Failed to delete link: ' + err.message); + } + }); + + document.getElementById('search-btn').addEventListener('click', () => loadLinks(searchInput.value)); + searchInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') loadLinks(searchInput.value); + }); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('action') === 'new') { + openModal(); + } + + loadLinks(); + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function formatDate(dateStr) { + if (!dateStr) return '-'; + const d = new Date(dateStr); + return d.toLocaleDateString(); + } +}); diff --git a/LinkSyncServer/static/js/main.js b/LinkSyncServer/static/js/main.js index 80a5b70..37582ab 100644 --- a/LinkSyncServer/static/js/main.js +++ b/LinkSyncServer/static/js/main.js @@ -15,7 +15,20 @@ document.addEventListener("DOMContentLoaded", function () { headers, }); if (!response.ok) { - throw new Error(`API error: ${response.status}`); + let errorMsg = `HTTP ${response.status}`; + try { + const error = await response.json(); + if (typeof error.detail === 'string') { + errorMsg = error.detail; + } else if (Array.isArray(error.detail)) { + errorMsg = error.detail.map(d => d.msg || d).join(', '); + } else if (error.detail && typeof error.detail === 'object') { + errorMsg = error.detail.detail || error.detail.msg || JSON.stringify(error.detail); + } + } catch { + // ignore parse error, use default + } + throw new Error(errorMsg); } return response.json(); } @@ -50,6 +63,15 @@ document.addEventListener("DOMContentLoaded", function () { body: JSON.stringify(data), }); }, + async updateCollection(id, data) { + return apiFetch(`/collections/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); + }, + async deleteCollection(id) { + return apiFetch(`/collections/${id}`, { method: "DELETE" }); + }, async executeQuery(expression, limit = 20) { return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`); }, @@ -67,8 +89,41 @@ document.addEventListener("DOMContentLoaded", function () { localStorage.setItem("token", data.access_token); return data; }, + async getMe() { + return apiFetch("/auth/me"); + }, logout() { localStorage.removeItem("token"); + localStorage.removeItem("user"); + }, + async getUsers() { + return apiFetch("/admin/users"); + }, + async createUser(data) { + return apiFetch("/admin/users", { + method: "POST", + body: JSON.stringify(data), + }); + }, + async updateUser(id, data) { + return apiFetch(`/admin/users/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); + }, + async deleteUser(id) { + return apiFetch(`/admin/users/${id}`, { method: "DELETE" }); + }, + async getApiKeys() { + return apiFetch("/auth/api-keys"); + }, + async createApiKey(name) { + return apiFetch(`/auth/api-key?name=${encodeURIComponent(name)}`, { + method: "POST", + }); + }, + async deleteApiKey(id) { + return apiFetch(`/auth/api-key/${id}`, { method: "DELETE" }); }, }; }); diff --git a/LinkSyncServer/templates/admin.html b/LinkSyncServer/templates/admin.html new file mode 100644 index 0000000..408b1ca --- /dev/null +++ b/LinkSyncServer/templates/admin.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} + +{% block title %}Admin - LinkSync{% endblock %} + +{% block content %} + + +
+
Loading users...
+
+ + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/LinkSyncServer/templates/apikeys.html b/LinkSyncServer/templates/apikeys.html new file mode 100644 index 0000000..143e50e --- /dev/null +++ b/LinkSyncServer/templates/apikeys.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %}API Keys - LinkSync{% endblock %} + +{% block content %} + + +
+
Loading API keys...
+
+ + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/LinkSyncServer/templates/base.html b/LinkSyncServer/templates/base.html index 56e79f7..4f85651 100644 --- a/LinkSyncServer/templates/base.html +++ b/LinkSyncServer/templates/base.html @@ -10,13 +10,19 @@
@@ -26,6 +32,27 @@

LinkSyncServer © 2026

+ {% block extra_js %}{% endblock %} diff --git a/LinkSyncServer/templates/collections.html b/LinkSyncServer/templates/collections.html new file mode 100644 index 0000000..fb308e6 --- /dev/null +++ b/LinkSyncServer/templates/collections.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block title %}Collections - LinkSync{% endblock %} + +{% block content %} + + +
+
Loading collections...
+
+ + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/LinkSyncServer/templates/dashboard.html b/LinkSyncServer/templates/dashboard.html new file mode 100644 index 0000000..b3559ea --- /dev/null +++ b/LinkSyncServer/templates/dashboard.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - LinkSync{% endblock %} + +{% block content %} +
+

Dashboard

+

Welcome,

+
+ +
+
+ +
Total Links
+ View all → +
+
+
-
+
Collections
+ View all → +
+
+
-
+
API Keys
+ Manage → +
+
+ +
+

Quick Actions

+ +
+ + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/LinkSyncServer/templates/index.html b/LinkSyncServer/templates/index.html index c83b9ec..7ef83fd 100644 --- a/LinkSyncServer/templates/index.html +++ b/LinkSyncServer/templates/index.html @@ -1,60 +1,15 @@ -{% extends "base.html" %} - -{% block title %}LinkSync - Home{% endblock %} - -{% block content %} -
-

LinkSync Server

-

Self-hosted bookmark server with advanced collection and query capabilities.

- -
- - - -
-

Features

- -
- -
-

Query Syntax

-
- ('term1', 'term2') OR tagA AND tagB XOR url:example.com -
-

Precedence: () > XOR > AND > OR

-
-{% endblock %} + + + + + + LinkSync + + + +

Redirecting...

+ + diff --git a/LinkSyncServer/templates/links.html b/LinkSyncServer/templates/links.html new file mode 100644 index 0000000..f783fc4 --- /dev/null +++ b/LinkSyncServer/templates/links.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block title %}Links - LinkSync{% endblock %} + +{% block content %} + + + + + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/LinkSyncServer/templates/login.html b/LinkSyncServer/templates/login.html new file mode 100644 index 0000000..2f4a529 --- /dev/null +++ b/LinkSyncServer/templates/login.html @@ -0,0 +1,74 @@ + + + + + + Login - LinkSync + + + +
+ +
+ + + diff --git a/LinkSyncServer/tests/conftest.py b/LinkSyncServer/tests/conftest.py index f1eac11..c1900cb 100644 --- a/LinkSyncServer/tests/conftest.py +++ b/LinkSyncServer/tests/conftest.py @@ -1,32 +1,88 @@ """ LinkSyncServer - Test Configuration + +Database lifecycle: +- pytest_configure: Creates fresh test database before any tests run +- pytest_unconfigure: Destroys test database after all tests complete +- Per-test: Transaction rollback ensures test isolation """ +import os import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool -from models.base import Base, get_engine +from models.base import Base +import models.base -SQLALCHEMY_DATABASE_URL = "sqlite:///test_linksync.db" -engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) -TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +TEST_DATABASE_URL = "sqlite:///test_linksync.db" + + +def pytest_configure(config): + """Called before any tests run. Creates fresh test database.""" + if os.path.exists("test_linksync.db"): + os.remove("test_linksync.db") + + test_engine = create_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=test_engine) + + # Override the module-level get_engine to return our test engine + original_get_engine = models.base.get_engine + original_get_session = models.base.get_session + + def test_get_engine(): + return test_engine + + def test_get_session(): + Session = sessionmaker(bind=test_engine) + return Session() + + models.base.get_engine = test_get_engine + models.base.get_session = test_get_session + + # Store originals for cleanup + config._test_engine = test_engine + config._original_get_engine = original_get_engine + config._original_get_session = original_get_session + + +def pytest_unconfigure(config): + """Called after all tests complete. Destroys test database.""" + # Restore original functions + if hasattr(config, "_original_get_engine"): + models.base.get_engine = config._original_get_engine + models.base.get_session = config._original_get_session + + # Drop all tables + if hasattr(config, "_test_engine"): + Base.metadata.drop_all(bind=config._test_engine) + config._test_engine.dispose() + + # Remove the test database file + if os.path.exists("test_linksync.db"): + os.remove("test_linksync.db") @pytest.fixture(scope="session") -def test_engine(): - Base.metadata.create_all(bind=engine) - yield engine - Base.metadata.drop_all(bind=engine) +def test_engine(pytestconfig): + """Provides the test engine for direct database access in tests.""" + return pytestconfig._test_engine @pytest.fixture def db_session(test_engine): + """Provides a transactional session that rolls back after each test.""" connection = test_engine.connect() transaction = connection.begin() - session = TestingSessionLocal(bind=connection) + Session = sessionmaker(bind=connection) + session = Session() yield session @@ -37,6 +93,7 @@ def db_session(test_engine): @pytest.fixture def client(): + """Provides a TestClient with the test database already configured.""" from app import app with TestClient(app) as c: yield c diff --git a/LinkSyncServer/tests/test_admin.py b/LinkSyncServer/tests/test_admin.py new file mode 100644 index 0000000..b2d0a88 --- /dev/null +++ b/LinkSyncServer/tests/test_admin.py @@ -0,0 +1,223 @@ +""" +LinkSyncServer - Admin API Tests +""" + +import uuid +import pytest +from fastapi.testclient import TestClient + + +class TestAdmin: + def test_list_users(self, client: TestClient, admin_token: str): + response = client.get( + "/api/admin/users", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_create_user(self, client: TestClient, admin_token: str): + unique = str(uuid.uuid4())[:8] + response = client.post( + "/api/admin/users", + json={ + "username": f"admin_created_{unique}", + "email": f"admin_{unique}@example.com", + "password": "testpass123", + "role": "user", + "is_active": True, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["username"] == f"admin_created_{unique}" + + def test_create_admin_user(self, client: TestClient, admin_token: str): + unique = str(uuid.uuid4())[:8] + response = client.post( + "/api/admin/users", + json={ + "username": f"admin_user_{unique}", + "email": f"adminuser_{unique}@example.com", + "password": "testpass123", + "role": "admin", + "is_active": True, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 201 + assert response.json()["role"] == "admin" + + def test_create_user_duplicate(self, client: TestClient, admin_token: str): + unique = str(uuid.uuid4())[:8] + client.post( + "/api/admin/users", + json={ + "username": f"dup_admin_{unique}", + "email": f"dupadmin_{unique}@example.com", + "password": "testpass123", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + response = client.post( + "/api/admin/users", + json={ + "username": f"dup_admin_{unique}", + "email": f"dupadmin2_{unique}@example.com", + "password": "testpass123", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 400 + + def test_get_user(self, client: TestClient, admin_token: str): + users = client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}).json() + if users: + user_id = users[0]["id"] + response = client.get( + f"/api/admin/users/{user_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert "username" in response.json() + + def test_get_user_not_found(self, client: TestClient, admin_token: str): + response = client.get( + "/api/admin/users/00000000-0000-0000-0000-000000000000", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 404 + + def test_update_user(self, client: TestClient, admin_token: str): + unique = str(uuid.uuid4())[:8] + create_resp = client.post( + "/api/admin/users", + json={ + "username": f"update_user_{unique}", + "email": f"update_{unique}@example.com", + "password": "testpass123", + "role": "user", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + user_id = create_resp.json()["id"] + response = client.put( + f"/api/admin/users/{user_id}", + json={"email": f"updated_{unique}@example.com"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert response.json()["email"] == f"updated_{unique}@example.com" + + def test_update_user_role(self, client: TestClient, admin_token: str): + unique = str(uuid.uuid4())[:8] + create_resp = client.post( + "/api/admin/users", + json={ + "username": f"role_user_{unique}", + "email": f"role_{unique}@example.com", + "password": "testpass123", + "role": "user", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + user_id = create_resp.json()["id"] + response = client.put( + f"/api/admin/users/{user_id}", + json={"role": "admin"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert response.json()["role"] == "admin" + + def test_deactivate_user(self, client: TestClient, admin_token: str): + unique = str(uuid.uuid4())[:8] + create_resp = client.post( + "/api/admin/users", + json={ + "username": f"deact_user_{unique}", + "email": f"deact_{unique}@example.com", + "password": "testpass123", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + user_id = create_resp.json()["id"] + response = client.put( + f"/api/admin/users/{user_id}", + json={"is_active": False}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert response.json()["is_active"] is False + + def test_delete_user(self, client: TestClient, admin_token: str): + unique = str(uuid.uuid4())[:8] + create_resp = client.post( + "/api/admin/users", + json={ + "username": f"del_user_{unique}", + "email": f"del_{unique}@example.com", + "password": "testpass123", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + user_id = create_resp.json()["id"] + response = client.delete( + f"/api/admin/users/{user_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + + def test_delete_self_not_allowed(self, client: TestClient, admin_token: str): + users = client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}).json() + admin_user = next((u for u in users if u["username"] == "admin"), None) + if admin_user: + response = client.delete( + f"/api/admin/users/{admin_user['id']}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 400 + + def test_stats(self, client: TestClient, admin_token: str): + response = client.get( + "/api/admin/stats", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert "total_users" in data + assert "total_bookmarks" in data + + def test_audit_log(self, client: TestClient, admin_token: str): + response = client.get( + "/api/admin/audit", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_admin_required_for_user_list(self, client: TestClient): + unique = str(uuid.uuid4())[:8] + client.post( + "/api/auth/register", + json={ + "username": f"regular_{unique}", + "email": f"regular_{unique}@example.com", + "password": "testpass123", + }, + ) + resp = client.post( + "/api/auth/login", + data={"username": f"regular_{unique}", "password": "testpass123"}, + ) + token = resp.json()["access_token"] + response = client.get( + "/api/admin/users", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + def test_unauthenticated_admin_access(self, client: TestClient): + response = client.get("/api/admin/users") + assert response.status_code == 401 diff --git a/LinkSyncServer/tests/test_auth_extended.py b/LinkSyncServer/tests/test_auth_extended.py new file mode 100644 index 0000000..751119c --- /dev/null +++ b/LinkSyncServer/tests/test_auth_extended.py @@ -0,0 +1,95 @@ +""" +LinkSyncServer - Auth API Tests (extended) +""" + +import uuid +import pytest +from fastapi.testclient import TestClient + + +class TestAuthExtended: + def test_list_api_keys(self, client: TestClient, admin_token: str): + response = client.get( + "/api/auth/api-keys", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_list_api_keys_empty(self, client: TestClient, admin_token: str): + response = client.get( + "/api/auth/api-keys", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_create_and_list_api_key(self, client: TestClient, admin_token: str): + import uuid + key_name = f"test-key-{uuid.uuid4().hex[:8]}" + client.post( + "/api/auth/api-key", + params={"name": key_name}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + response = client.get( + "/api/auth/api-keys", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + keys = response.json() + assert any(k["name"] == key_name for k in keys) + + def test_get_api_key(self, client: TestClient, admin_token: str): + import uuid + key_name = f"get-key-{uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/auth/api-key", + params={"name": key_name}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + key_id = create_resp.json()["key_id"] + response = client.get( + f"/api/auth/api-key/{key_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == key_name + + def test_get_api_key_not_found(self, client: TestClient, admin_token: str): + response = client.get( + "/api/auth/api-key/00000000-0000-0000-0000-000000000000", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 404 + + def test_delete_api_key(self, client: TestClient, admin_token: str): + import uuid + key_name = f"del-key-{uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/auth/api-key", + params={"name": key_name}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + key_id = create_resp.json()["key_id"] + response = client.delete( + f"/api/auth/api-key/{key_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + + def test_delete_api_key_not_found(self, client: TestClient, admin_token: str): + response = client.delete( + "/api/auth/api-key/00000000-0000-0000-0000-000000000000", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 404 + + def test_api_key_requires_auth(self, client: TestClient): + response = client.get("/api/auth/api-keys") + assert response.status_code == 401 + + def test_create_api_key_requires_auth(self, client: TestClient): + response = client.post("/api/auth/api-key", params={"name": "test"}) + assert response.status_code == 401 diff --git a/LinkSyncServer/tests/test_sync.py b/LinkSyncServer/tests/test_sync.py new file mode 100644 index 0000000..05b561f --- /dev/null +++ b/LinkSyncServer/tests/test_sync.py @@ -0,0 +1,93 @@ +""" +LinkSyncServer - Sync API Tests +""" + +import uuid +import pytest +from fastapi.testclient import TestClient + + +class TestSync: + def test_sync_list_collections(self, client: TestClient): + response = client.get("/api/sync/collections") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_sync_get_collection_not_found(self, client: TestClient, auth_headers: dict): + response = client.get( + "/api/sync/collections/00000000-0000-0000-0000-000000000000", + headers=auth_headers, + ) + assert response.status_code == 404 + + def test_sync_create_collection_and_get(self, client: TestClient, auth_headers: dict): + col_name = f"Sync Get Test {uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/collections/", + json={ + "name": col_name, + "description": "Test", + "query_type": "static", + "is_public": False, + "link_ids": [], + }, + headers=auth_headers, + ) + collection_id = create_resp.json()["id"] + response = client.get( + f"/api/sync/collections/{collection_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["name"] == col_name + + def test_sync_add_links(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict): + bm_data = sample_bookmark_data.copy() + bm_data["url"] = f"https://sync-test-{uuid.uuid4().hex[:8]}.com" + bm_data["title"] = f"Sync Test {uuid.uuid4().hex[:8]}" + bm_resp = client.post("/api/links/", json=bm_data, headers=auth_headers) + bookmark_id = bm_resp.json()["id"] + + col_name = f"Sync Add Links {uuid.uuid4().hex[:8]}" + col_resp = client.post( + "/api/collections/", + json={ + "name": col_name, + "query_type": "static", + "is_public": False, + "link_ids": [], + }, + headers=auth_headers, + ) + collection_id = col_resp.json()["id"] + + response = client.post( + f"/api/sync/collections/{collection_id}/add-links", + json=[bookmark_id], + headers=auth_headers, + ) + assert response.status_code == 200 + + def test_sync_delete_collection(self, client: TestClient, auth_headers: dict): + col_name = f"Sync Delete {uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/collections/", + json={ + "name": col_name, + "query_type": "static", + "is_public": False, + "link_ids": [], + }, + headers=auth_headers, + ) + collection_id = create_resp.json()["id"] + + response = client.delete( + f"/api/sync/collections/{collection_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + + def test_sync_list_collections_no_auth(self, client: TestClient): + response = client.get("/api/sync/collections") + assert response.status_code == 200 diff --git a/LinkSyncServer/tests/test_tags.py b/LinkSyncServer/tests/test_tags.py new file mode 100644 index 0000000..eb1b827 --- /dev/null +++ b/LinkSyncServer/tests/test_tags.py @@ -0,0 +1,118 @@ +""" +LinkSyncServer - Tags API Tests +""" + +import uuid +import pytest +from fastapi.testclient import TestClient + + +class TestTags: + def test_list_tags_empty(self, client: TestClient, auth_headers: dict): + response = client.get("/api/tags/", headers=auth_headers) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_create_tag(self, client: TestClient, auth_headers: dict): + tag_name = f"test-tag-{uuid.uuid4().hex[:8]}" + response = client.post( + "/api/tags/", + json={"name": tag_name, "color": "#ff0000", "description": "A test tag"}, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == tag_name + + def test_create_tag_duplicate(self, client: TestClient, auth_headers: dict): + tag_name = f"dup-tag-{uuid.uuid4().hex[:8]}" + client.post( + "/api/tags/", + json={"name": tag_name}, + headers=auth_headers, + ) + response = client.post( + "/api/tags/", + json={"name": tag_name}, + headers=auth_headers, + ) + assert response.status_code == 400 + + def test_list_tags(self, client: TestClient, auth_headers: dict): + client.post("/api/tags/", json={"name": f"list-tag-1-{uuid.uuid4().hex[:8]}"}, headers=auth_headers) + client.post("/api/tags/", json={"name": f"list-tag-2-{uuid.uuid4().hex[:8]}"}, headers=auth_headers) + response = client.get("/api/tags/", headers=auth_headers) + assert response.status_code == 200 + assert len(response.json()) >= 2 + + def test_get_tag(self, client: TestClient, auth_headers: dict): + tag_name = f"get-tag-{uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/tags/", + json={"name": tag_name}, + headers=auth_headers, + ) + tag_id = create_resp.json()["id"] + response = client.get(f"/api/tags/{tag_id}", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["name"] == tag_name + + def test_get_tag_not_found(self, client: TestClient, auth_headers: dict): + response = client.get("/api/tags/00000000-0000-0000-0000-000000000000", headers=auth_headers) + assert response.status_code == 404 + + def test_get_tag_by_name(self, client: TestClient, auth_headers: dict): + tag_name = f"find-by-name-{uuid.uuid4().hex[:8]}" + client.post("/api/tags/", json={"name": tag_name}, headers=auth_headers) + response = client.get(f"/api/tags/name/{tag_name}", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["name"] == tag_name + + def test_get_tag_by_name_not_found(self, client: TestClient, auth_headers: dict): + response = client.get("/api/tags/name/nonexistent-tag-xyz", headers=auth_headers) + assert response.status_code == 404 + + def test_update_tag(self, client: TestClient, auth_headers: dict): + tag_name = f"update-tag-{uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/tags/", + json={"name": tag_name, "color": "#000000"}, + headers=auth_headers, + ) + tag_id = create_resp.json()["id"] + response = client.put( + f"/api/tags/{tag_id}", + json={"color": "#ffffff", "description": "Updated"}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["color"] == "#ffffff" + + def test_delete_tag(self, client: TestClient, auth_headers: dict): + tag_name = f"delete-tag-{uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/tags/", + json={"name": tag_name}, + headers=auth_headers, + ) + tag_id = create_resp.json()["id"] + response = client.delete(f"/api/tags/{tag_id}", headers=auth_headers) + assert response.status_code == 200 + + def test_get_tag_links(self, client: TestClient, auth_headers: dict): + tag_name = f"links-tag-{uuid.uuid4().hex[:8]}" + create_resp = client.post( + "/api/tags/", + json={"name": tag_name}, + headers=auth_headers, + ) + tag_id = create_resp.json()["id"] + response = client.get(f"/api/tags/{tag_id}/links", headers=auth_headers) + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_tag_count(self, client: TestClient, auth_headers: dict): + response = client.get("/api/tags/count", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "count" in data