Complete LinkSyncServer and LinkSyncExtension implementation

LinkSyncServer:
- Fix app.py imports, add CORS middleware, lifespan events
- Create api/routes.py router aggregator
- Create config/settings.py for centralized configuration
- Rewrite models/base.py with proper relationships and serialization
- Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags)
- Add admin endpoints (user management, stats, audit log)
- Complete query parser with recursive descent and proper precedence
- Complete query executor with set operations and field filters
- Set up Alembic migrations with initial schema
- Create web interface (templates, CSS, JS)
- Add 42 passing tests (auth, links, collections, queries)
- Add deploy.ps1 and deploy.sh scripts
- Update README with deployment workflow

LinkSyncExtension:
- Create utils/api.js (REST client with retries, auth, error handling)
- Create utils/sync.js (3 sync modes + conflict detection)
- Create utils/collection.js (collection management)
- Create utils/query-engine.js (client-side query parser)
- Rewrite background.js (sync loop, bookmark events, message routing)
- Rewrite popup.js (tabs, settings modal, notifications, CRUD)
- Update popup.html (tabbed interface, query builder, modal)
- Update popup.css (full redesign)
- Create content/content.js (page metadata extraction)
- Create options.html/js (dedicated settings page)
- Generate icons (48x48, 96x96)
- Update manifest.json (host permissions, content scripts, options)
- Create AGENTS.md
This commit is contained in:
DavidSaylor
2026-05-19 13:21:26 -05:00
parent c5d3912070
commit 09d30427f4
54 changed files with 5918 additions and 3177 deletions

View File

@@ -0,0 +1,187 @@
"""
LinkSyncServer - Admin Endpoints
"""
import uuid
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel, EmailStr, Field
from api.endpoints.auth import hash_password, require_admin
from models.base import AuditLog, Bookmark, Collection, Tag, User, get_session
router = APIRouter(prefix="/api/admin", tags=["Admin"])
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
role: str = Field(default="user", pattern="^(admin|user)$")
is_active: bool = True
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
role: Optional[str] = Field(None, pattern="^(admin|user)$")
is_active: Optional[bool] = None
password: Optional[str] = None
class SettingsUpdate(BaseModel):
debug: Optional[bool] = None
cors_origins: Optional[str] = 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,
):
db = get_session()
try:
users = db.query(User).order_by(User.created_at.desc()).offset(offset).limit(limit).all()
return [u.to_dict() for u in users]
finally:
db.close()
@router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_user(
data: UserCreate,
current_admin: dict = require_admin,
):
db = get_session()
try:
existing = db.query(User).filter(
(User.username == data.username) | (User.email == data.email)
).first()
if existing:
raise HTTPException(status_code=400, detail="Username or email already exists")
user = User(
id=str(uuid.uuid4()),
username=data.username,
email=data.email,
password_hash=hash_password(data.password),
role=data.role,
is_active=data.is_active,
)
db.add(user)
db.commit()
db.refresh(user)
return user.to_dict()
finally:
db.close()
@router.get("/users/{user_id}", response_model=dict)
async def get_user(
user_id: str,
current_admin: dict = require_admin,
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user.to_dict()
finally:
db.close()
@router.put("/users/{user_id}", response_model=dict)
async def update_user(
user_id: str,
data: UserUpdate,
current_admin: dict = require_admin,
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
update_data = data.model_dump(exclude_unset=True)
if "password" in update_data:
update_data["password_hash"] = hash_password(update_data.pop("password"))
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user.to_dict()
finally:
db.close()
@router.delete("/users/{user_id}", response_model=dict)
async def delete_user(
user_id: str,
current_admin: dict = require_admin,
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.username == current_admin.get("username"):
raise HTTPException(status_code=400, detail="Cannot delete yourself")
db.delete(user)
db.commit()
return {"message": "User deleted successfully", "deleted_id": user_id}
finally:
db.close()
@router.get("/stats", response_model=dict)
async def get_system_stats(current_admin: dict = require_admin):
db = get_session()
try:
return {
"total_users": db.query(User).count(),
"total_bookmarks": db.query(Bookmark).count(),
"total_collections": db.query(Collection).count(),
"total_tags": db.query(Tag).count(),
"total_audit_logs": db.query(AuditLog).count(),
}
finally:
db.close()
@router.get("/audit", response_model=List[dict])
async def get_audit_log(
limit: int = Query(50, le=200, ge=1),
offset: int = Query(0, ge=0),
entity_type: Optional[str] = Query(None),
action: Optional[str] = Query(None),
current_admin: dict = require_admin,
):
db = get_session()
try:
query = db.query(AuditLog)
if entity_type:
query = query.filter(AuditLog.entity_type == entity_type)
if action:
query = query.filter(AuditLog.action == action)
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
return [
{
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"entity_type": log.entity_type,
"entity_id": log.entity_id,
"old_value": log.old_value,
"new_value": log.new_value,
"ip_address": log.ip_address,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
finally:
db.close()

View File

@@ -2,151 +2,275 @@
LinkSyncServer - Authentication Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import Optional
import secrets
import hashlib
import os
import secrets
from datetime import datetime, timedelta
from typing import Optional
import bcrypt
import jwt
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
from models.base import User, ApiKey
from models.base import get_engine
# Fix: Define get_db dependency
def get_db():
"""Get database engine/session for testing without full DB setup."""
return None # Mock - in production would return actual session
from config.settings import settings
from models.base import ApiKey, User, get_session
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
# JWT configuration
SECRET_KEY = secrets.token_urlsafe(32)
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 1440
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create JWT access token."""
class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
is_admin: bool = False
class TokenResponse(BaseModel):
access_token: str
token_type: str
user: dict
class ApiKeyResponse(BaseModel):
api_key: str
key_id: str
name: str
expires_at: Optional[str] = None
def hash_password(password: str) -> str:
return bcrypt.hashpw(
password.encode("utf-8"),
bcrypt.gensalt(rounds=settings.BCRYPT_COST_FACTOR),
).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.utcnow() + (
expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def get_user_from_token(token: str):
"""Get user from JWT token."""
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
)
username: str = payload.get("sub")
user_type: str = payload.get("type")
if user_type != "access":
raise HTTPException(status_code=401, detail="Invalid token type")
if username is None:
raise HTTPException(status_code=401, detail="Invalid token")
return {"username": username, "type": "access"}
return {"username": username, "role": payload.get("role", "user")}
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
def get_db():
session = get_session()
try:
yield session
finally:
session.close()
@router.post("/register", response_model=dict)
async def register(
username: str,
email: str,
password: str,
is_admin: bool = False,
):
"""Register new user."""
return {
"message": "User registered successfully",
"user": {
"id": "test-user-id",
"username": username,
"email": email,
"role": "admin" if is_admin else "user"
async def register(data: RegisterRequest):
db = get_session()
try:
existing = db.query(User).filter(
(User.username == data.username) | (User.email == data.email)
).first()
if existing:
raise HTTPException(status_code=400, detail="Username or email already exists")
user = User(
username=data.username,
email=data.email,
password_hash=hash_password(data.password),
role="admin" if data.is_admin else "user",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return {
"message": "User registered successfully",
"user": user.to_dict(),
}
}
finally:
db.close()
@router.post("/login", response_model=dict)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
admin_username: Optional[str] = None,
admin_password_hash: Optional[str] = None,
):
"""Login and get access token."""
# Admin login check
if admin_username and admin_password_hash:
if form_data.username == admin_username and form_data.password == admin_password_hash:
@router.post("/login", response_model=TokenResponse)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
db = get_session()
try:
if (
form_data.username == settings.ADMIN_USERNAME
and form_data.password == settings.ADMIN_PASSWORD
):
user = db.query(User).filter(User.username == settings.ADMIN_USERNAME).first()
if not user:
user = User(
username=settings.ADMIN_USERNAME,
email="admin@linksync.local",
password_hash=hash_password(settings.ADMIN_PASSWORD),
role="admin",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
token = create_access_token(
data={"sub": admin_username, "type": "access"}
data={"sub": user.username, "role": user.role, "type": "access"}
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": admin_username, "role": "admin"}
"user": {"username": user.username, "role": user.role},
}
# Regular user login - demo: accept any valid credentials
token = create_access_token(
data={"sub": form_data.username, "type": "access"}
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": form_data.username, "role": "user"}
}
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
token = create_access_token(
data={"sub": user.username, "role": user.role, "type": "access"}
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": user.username, "role": user.role},
}
finally:
db.close()
@router.post("/logout")
async def logout():
"""Logout (client-side token invalidation)."""
return {"message": "Logged out successfully"}
@router.post("/api-key", response_model=dict)
async def create_api_key(user_data: dict = {}):
"""Create new API key for authenticated user."""
key = secrets.token_urlsafe(64)
return {"api_key": key, "expires_in": None}
@router.post("/api-key", response_model=ApiKeyResponse)
async def create_api_key(
name: str = "default",
current_user: dict = Depends(get_current_user),
):
db = get_session()
try:
raw_key = secrets.token_urlsafe(64)
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
user = db.query(User).filter(User.username == current_user["username"]).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
api_key = ApiKey(
user_id=user.id,
key_hash=key_hash,
name=name,
is_active=True,
)
db.add(api_key)
db.commit()
db.refresh(api_key)
return {
"api_key": raw_key,
"key_id": api_key.id,
"name": api_key.name,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
}
finally:
db.close()
@router.get("/api-key/{key_id}")
async def get_api_key_info(key_id: str):
"""Get API key information."""
return {"key_id": key_id, "active": True}
async def get_api_key_info(
key_id: str,
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_key = db.query(ApiKey).filter(
ApiKey.id == 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,
"name": api_key.name,
"is_active": api_key.is_active,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
"created_at": api_key.created_at.isoformat() if api_key.created_at else None,
}
finally:
db.close()
@router.delete("/api-key/{key_id}")
async def delete_api_key(key_id: str):
"""Delete API key."""
return {"message": "API key deleted successfully"}
@router.get("/me", response_model=dict)
async def get_current_user_info(token: str = Depends(oauth2_scheme)):
"""Get current user info."""
user_data = get_user_from_token(token)
return {"username": user_data["username"]}
@router.get("/token", response_model=dict)
async def get_token_info(token: str = Depends(oauth2_scheme)):
"""Get token information."""
async def delete_api_key(
key_id: str,
current_user: dict = Depends(get_current_user),
):
db = get_session()
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return {"username": payload.get("sub"), "exp": payload.get("exp")}
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
user = db.query(User).filter(User.username == current_user["username"]).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
api_key = db.query(ApiKey).filter(
ApiKey.id == key_id, ApiKey.user_id == user.id
).first()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
db.delete(api_key)
db.commit()
return {"message": "API key deleted successfully"}
finally:
db.close()
@router.get("/me")
async def get_current_user_info(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")
return user.to_dict()
finally:
db.close()

View File

@@ -1,233 +1,258 @@
"""
LinkSyncServer - Collection CRUD Endpoints with SQLAlchemy
LinkSyncServer - Collection CRUD Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_, exists
from typing import List, Optional
import uuid
import logging
import uuid
from typing import List, Optional
from models.base import Base, Bookmark, Collection, AuditLog, get_engine, sessionmaker
from fastapi import APIRouter, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
import os
from sqlalchemy import and_, or_
from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, get_session
from queries.executor import execute_query
router = APIRouter(prefix="/api/collections", tags=["Collections"])
# Logging
logger = logging.getLogger(__name__)
class CollectionCreate(BaseModel):
name: str = Field(..., description="Collection name")
description: Optional[str] = Field(None, max_length=1024, description="Collection description")
query_type: str = Field(default="static", description="Static or dynamic collection")
description: Optional[str] = Field(None, max_length=1024)
query_type: str = Field(default="static", description="static or dynamic")
query_expression: Optional[dict] = Field(None, description="Query expression for dynamic collections")
is_public: bool = Field(default=False, description="Is collection public")
tags: Optional[List[str]] = Field(default_factory=list, description="Collection tags")
is_public: bool = Field(default=False)
link_ids: Optional[List[str]] = Field(default_factory=list, description="Link IDs for static collections")
class CollectionUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=255)
name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = Field(None, max_length=1024)
query_type: Optional[str] = Field(None)
query_expression: Optional[dict] = Field(None)
query_type: Optional[str] = None
query_expression: Optional[dict] = None
is_public: Optional[bool] = None
tags: Optional[List[str]] = Field(None)
class CollectionResponse(BaseModel):
id: str
name: str
description: Optional[str]
query_type: str
query_expression: Optional[dict]
is_public: bool
created_at: str
updated_at: str
tags: List[str]
def get_db():
"""Get database session."""
db_session = sessionmaker(get_engine())()
return db_session
def get_current_user(request: Request):
"""Get current authenticated user."""
SECRET_KEY = os.environ.get("SECRET_KEY")
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
def get_current_user_id(request: Request) -> Optional[str]:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
try:
import jwt
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return {"username": payload.get("sub"), "id": payload.get("sub")}
from config.settings import settings
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
return payload.get("sub")
except Exception:
pass
return {"username": "guest"}
return None
class CollectionManager:
"""Collection management helper."""
@staticmethod
def get_collection(collection_id: str) -> Optional[Collection]:
"""Get collection by ID."""
db = get_db()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
return collection
except Exception:
return None
@staticmethod
def create_collection(data: CollectionCreate, request: Request) -> Collection:
"""Create new collection."""
db = get_db()
def log_audit(db, action, entity_type, entity_id, user_id, old_value=None, new_value=None):
try:
audit = AuditLog(
action=action,
entity_type=entity_type,
entity_id=entity_id,
old_value=old_value,
new_value=new_value,
user_id=user_id,
)
db.add(audit)
db.commit()
except Exception:
db.rollback()
@router.get("/", response_model=List[dict])
async def list_collections(
limit: int = Query(20, le=100, ge=1),
offset: int = Query(0, ge=0),
request: Request = None,
):
db = get_session()
try:
user_id = 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)
)
else:
query = query.filter(Collection.is_public == True)
collections = query.order_by(Collection.created_at.desc()).offset(offset).limit(limit).all()
return [c.to_dict() for c in collections]
finally:
db.close()
@router.get("/{collection_id}", response_model=dict)
async def get_collection(collection_id: str):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == 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)
.all()
)
result["link_ids"] = [lb.bookmark_id for lb in links]
return result
finally:
db.close()
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_collection(data: CollectionCreate, request: Request):
db = get_session()
try:
user_id = get_current_user_id(request)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
collection = Collection(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
query_type=data.query_type,
query_expression=data.query_expression,
is_public=data.is_public,
tags=TagCollection(tags=data.tags or []),
created_by=user_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)
db.add(cb)
db.commit()
db.refresh(collection)
# Create audit log
user = get_current_user(request)
try:
audit = AuditLog(
action="create",
entity_type="Collection",
entity_id=collection.id,
old_value=None,
new_value=collection.dict(),
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return collection
@staticmethod
def update_collection(collection_id: str, data: CollectionUpdate, request: Request) -> Optional[Collection]:
"""Update collection."""
db = get_db()
log_audit(db, "create", "Collection", collection.id, user_id, new_value=collection.to_dict())
return collection.to_dict()
finally:
db.close()
@router.put("/{collection_id}", response_model=dict)
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()
if not collection:
return None
# Update fields
for field_name, value in data.dict().items():
if value is not None:
if hasattr(collection, field_name):
setattr(collection, field_name, value)
elif field_name == "tags":
if isinstance(value, list):
collection.tags.add(*value)
else:
collection.tags.update(str(value))
raise HTTPException(status_code=404, detail="Collection not found")
old_value = collection.to_dict()
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(collection, field, value)
db.commit()
db.refresh(collection)
# Create audit log
user = get_current_user(request)
try:
audit = AuditLog(
action="update",
entity_type="Collection",
entity_id=collection_id,
old_value=collection.dict(),
new_value=collection.dict(),
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
return collection
@staticmethod
def delete_collection(collection_id: str, request: Request) -> dict:
"""Delete collection."""
db = get_db()
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())
return collection.to_dict()
finally:
db.close()
@router.delete("/{collection_id}", response_model=dict)
async def delete_collection(collection_id: str, request: Request):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
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
).delete()
db.delete(collection)
db.commit()
# Create audit log
user = get_current_user(request)
try:
audit = AuditLog(
action="delete",
entity_type="Collection",
entity_id=collection_id,
old_value=collection.dict(),
new_value=None,
user_id=user.get("id")
)
db.add(audit)
db.commit()
except Exception:
pass
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}
@staticmethod
def get_collection_tags(collection_id: str) -> List[str]:
"""Get collection tags."""
db = get_db()
finally:
db.close()
@router.post("/{collection_id}/refresh", response_model=dict)
async def refresh_collection(collection_id: str):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
return []
return list(collection.tags)
@staticmethod
def get_collection_bookmarks(collection_id: str, limit: int = 50, offset: int = 0) -> List[Bookmark]:
"""
Get bookmarks for collection (static or dynamic).
For dynamic collections with query expression:
Use query executor to parse and filter bookmarks
"""
db = get_db()
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
return []
if collection.query_type == "static":
# Static collection: get all bookmarks
bookmarks = db.query(Bookmark).filter(Bookmark.collection_id == collection_id).limit(limit).offset(offset).all()
raise HTTPException(status_code=404, detail="Collection not found")
if collection.query_type != "dynamic":
raise HTTPException(status_code=400, detail="Only dynamic collections can be refreshed")
if collection.query_expression:
bookmarks = execute_query(collection.query_expression)
else:
# Dynamic collection: query expression
# TODO: Use query executor to parse expression (executor module)
bookmarks = db.query(Bookmark).limit(limit).offset(offset).all()
return bookmarks
bookmarks = []
return {
"collection_id": collection_id,
"matched_count": len(bookmarks),
"bookmarks": bookmarks,
}
finally:
db.close()
@router.post("/{collection_id}/add-links", response_model=dict)
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()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.query_type != "static":
raise HTTPException(status_code=400, detail="Can only add links to static collections")
existing = {
cb.bookmark_id
for cb in db.query(CollectionBookmark)
.filter(CollectionBookmark.collection_id == collection_id)
.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))
added += 1
db.commit()
return {"message": f"Added {added} links", "added_count": added}
finally:
db.close()
@router.delete("/{collection_id}/remove-links", response_model=dict)
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()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
removed = (
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == collection_id,
CollectionBookmark.bookmark_id.in_(link_ids),
)
.delete(synchronize_session=False)
)
db.commit()
return {"message": f"Removed {removed} links", "removed_count": removed}
finally:
db.close()

View File

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

View File

@@ -1,253 +1,71 @@
"""
LinkSyncServer - Query Engine
LinkSyncServer - Query Engine Endpoints
"""
from fastapi import APIRouter, HTTPException
from typing import List, Optional, Dict, Any
import re
import uuid
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from models.base import Bookmark, get_session
from queries.executor import execute_query
from queries.parser import QueryParser
router = APIRouter(prefix="/api/queries", tags=["Queries"])
def tokenize(query: str) -> List[str]:
"""Tokenize query string."""
# Remove parentheses first, tokenize, then track nesting
tokens = []
current_token = ""
paren_depth = 0
i = 0
while i < len(query):
c = query[i]
if c == '(':
paren_depth += 1
current_token += c
elif c == ')':
paren_depth -= 1
current_token += c
elif c in ' \t\n' or paren_depth == 0 and c in ' ,':
if current_token:
tokens.append(current_token)
current_token = ""
else:
current_token += c
i += 1
if current_token:
tokens.append(current_token)
return tokens
class TermSet:
"""Term set: ('term1', 'term2') -> OR operation"""
def __init__(self, terms: List[str]):
self.terms = terms
self.operation = "OR"
def to_dict(self) -> Dict[str, Any]:
return {
"type": "term_set",
"terms": self.terms,
"operation": self.operation
}
class TagFilter:
"""Tag-based filter"""
def __init__(self, tag_name: str):
self.tag_name = tag_name
self.operation = "TAG"
def to_dict(self) -> Dict[str, Any]:
return {
"type": "tag_filter",
"tag_name": self.tag_name,
"operation": self.operation
}
class FieldFilter:
"""Field-based filter (e.g., url:example.com)"""
def __init__(self, field: str, value: str):
self.field = field
self.value = value
self.operation = "FIELD"
def to_dict(self) -> Dict[str, Any]:
return {
"type": "field_filter",
"field": self.field,
"value": self.value,
"operation": self.operation
}
class ANDNode:
"""AND operation node"""
def __init__(self, left, right):
self.left = left
self.right = right
self.operation = "AND"
def to_dict(self) -> Dict[str, Any]:
return {
"type": "binary",
"operation": self.operation,
"left": self.left.to_dict(),
"right": self.right.to_dict()
}
class ORNode:
"""OR operation node"""
def __init__(self, left, right):
self.left = left
self.right = right
self.operation = "OR"
def to_dict(self) -> Dict[str, Any]:
return {
"type": "binary",
"operation": self.operation,
"left": self.left.to_dict(),
"right": self.right.to_dict()
}
class XORNode:
"""XOR operation node"""
def __init__(self, left, right):
self.left = left
self.right = right
self.operation = "XOR"
def to_dict(self) -> Dict[str, Any]:
return {
"type": "binary",
"operation": self.operation,
"left": self.left.to_dict(),
"right": self.right.to_dict()
}
class NOTNode:
"""NOT operation node"""
def __init__(self, child):
self.child = child
self.operation = "NOT"
def to_dict(self) -> Dict[str, Any]:
return {
"type": "unary",
"operation": self.operation,
"child": self.child.to_dict()
}
def parse_query(query: str) -> Dict[str, Any]:
"""
Parse query expression: ('term1', 'term2') OR tagA AND tagB XOR url:example.com
Precedence: () > XOR > AND > OR
"""
tokens = tokenize(query)
# Remove parentheses and tokenize
tokens = tokenize(query)
# Simple parser for basic queries
# For full parser, would need recursive descent
# Handle term sets: ('term1', 'term2')
term_set = None
i = 0
while i < len(tokens):
token = tokens[i]
if token.startswith('(') and tokens[i].endswith(')'):
# Extract terms from tuple
inner = token[1:-1]
terms = [t.strip("'\"") for t in inner.split(',')]
term_set = TermSet(terms)
i += 1
else:
break
if not term_set:
# Parse as simple expression
# This is a simplified parser for demo
return {"type": "term_set", "terms": []}
return term_set.to_dict()
def execute_query(query_expression: dict, all_bookmarks: List[dict]) -> List[dict]:
"""
Execute query expression against bookmark list.
For demo, returns mock results.
"""
# Query AST evaluation would go here
# For now, return mock results
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com/result",
"title": "Query Result",
"description": "A result from the query",
"notes": "",
"tags": ["query", "result"],
"favicon_url": None,
"path": "/Query Result",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
]
@router.post("/parse", response_model=Dict[str, Any])
async def parse_expression(query: str):
"""Parse and validate query expression."""
parsed = parse_query(query)
return {
"expression": query,
"parsed": parsed,
"valid": True
}
async def parse_expression(expression: str):
try:
parser = QueryParser()
parsed = parser.parse(expression)
return {
"expression": expression,
"parsed": parsed,
"valid": True,
}
except Exception as e:
return {
"expression": expression,
"parsed": None,
"valid": False,
"error": str(e),
}
@router.post("/execute", response_model=List[dict])
async def execute(query_expression: dict, limit: int = 20):
"""Execute query against bookmarks."""
# For demo, return mock results
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com/queried",
"title": "Queried Item",
"description": "Item from query",
"notes": "",
"tags": ["queried"],
"favicon_url": None,
"path": "/Queried",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
]
async def execute(expression: str, limit: int = 20, offset: int = 0):
db = get_session()
try:
parser = QueryParser()
parsed = parser.parse(expression)
if not parsed:
raise HTTPException(status_code=400, detail="Invalid query expression")
all_bookmarks = db.query(Bookmark).all()
results = execute_query(parsed, [b.to_dict() for b in all_bookmarks])
return results[offset : offset + limit]
finally:
db.close()
@router.get("/{query_id}", response_model=Dict[str, Any])
async def get_saved_query(query_id: str):
"""Get saved query by ID."""
return {
"id": query_id,
"name": "Example Query",
"description": "Example query description",
"expression": "('work', 'dev') OR tag:work",
"query_type": "dynamic",
"is_public": False,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
db = get_session()
try:
from models.base import Collection
collection = db.query(Collection).filter(Collection.id == query_id).first()
if not collection or collection.query_type != "dynamic":
raise HTTPException(status_code=404, detail="Saved query not found")
return {
"id": collection.id,
"name": collection.name,
"description": collection.description,
"expression": collection.query_expression,
"query_type": collection.query_type,
"is_public": collection.is_public,
"created_at": collection.created_at.isoformat() if collection.created_at else None,
"updated_at": collection.updated_at.isoformat() if collection.updated_at else None,
}
finally:
db.close()

View File

@@ -2,29 +2,33 @@
LinkSyncServer - Sync Endpoint for Browser Extension
"""
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any
import uuid
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from models.base import Bookmark, get_session
router = APIRouter(prefix="/api/sync", tags=["Sync"])
class SyncConfig(BaseModel):
mode: str # "bi-directional", "browser-authoritative", "server-authoritative"
mode: str = Field(..., description="bi-directional, browser-authoritative, or server-authoritative")
deletions_enabled: bool = False
class BookmarkData(BaseModel):
class BookmarkSyncData(BaseModel):
id: str
url: str
title: str
description: str
notes: str
tags: List[str]
favicon_url: str
path: str
visit_count: int
is_bookmarked: bool
description: str = ""
notes: str = ""
tags: List[str] = Field(default_factory=list)
favicon_url: str = ""
path: str = ""
visit_count: int = 0
is_bookmarked: bool = False
class SyncResponse(BaseModel):
@@ -32,119 +36,178 @@ class SyncResponse(BaseModel):
synced_count: int
def mock_apply_sync(sync_config: SyncConfig, browser_bookmarks: List[Dict]) -> SyncResponse:
"""
Apply sync based on mode.
For demo, return mock actions.
"""
actions = []
for bookmark in browser_bookmarks:
if sync_config.mode == "bi-directional":
actions.append({
"type": "create" if not bookmark.get("from_server", False) else "update",
"link_id": bookmark["id"],
"message": "Synced from browser"
})
elif sync_config.mode == "browser-authoritative":
actions.append({
"type": "update",
"link_id": bookmark["id"],
"message": "Overwritten from browser"
})
elif sync_config.mode == "server-authoritative":
actions.append({
"type": "download",
"link_id": bookmark["id"],
"message": "Downloaded from server"
})
# If deletions enabled, would remove stale bookmarks here
return SyncResponse(
actions=actions,
synced_count=len(actions)
)
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()}
for bm in browser_bookmarks:
existing = server_bookmarks.get(bm.id)
def mock_get_server_bookmarks() -> List[Dict]:
"""Get bookmarks from server (mock)."""
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com/example",
"title": "Example",
"description": "An example",
"notes": "",
"tags": ["example"],
"favicon_url": None,
"path": "/Example",
"visit_count": 0,
"is_bookmarked": False
}
]
if sync_config.mode == "bi-directional":
if existing:
existing.url = bm.url
existing.title = bm.title
existing.description = bm.description
existing.notes = bm.notes
existing.tags = bm.tags
existing.favicon_url = bm.favicon_url
existing.path = bm.path
existing.visit_count = bm.visit_count
existing.is_bookmarked = bm.is_bookmarked
actions.append({"type": "update", "link_id": bm.id})
else:
new_bm = Bookmark(
id=bm.id,
url=bm.url,
title=bm.title,
description=bm.description,
notes=bm.notes,
tags=bm.tags,
favicon_url=bm.favicon_url,
path=bm.path,
visit_count=bm.visit_count,
is_bookmarked=bm.is_bookmarked,
)
db.add(new_bm)
actions.append({"type": "create", "link_id": bm.id})
elif sync_config.mode == "browser-authoritative":
if existing:
existing.url = bm.url
existing.title = bm.title
existing.description = bm.description
existing.notes = bm.notes
existing.tags = bm.tags
existing.favicon_url = bm.favicon_url
existing.path = bm.path
existing.visit_count = bm.visit_count
existing.is_bookmarked = bm.is_bookmarked
actions.append({"type": "update", "link_id": bm.id})
else:
new_bm = Bookmark(
id=bm.id,
url=bm.url,
title=bm.title,
description=bm.description,
notes=bm.notes,
tags=bm.tags,
favicon_url=bm.favicon_url,
path=bm.path,
visit_count=bm.visit_count,
is_bookmarked=bm.is_bookmarked,
)
db.add(new_bm)
actions.append({"type": "create", "link_id": bm.id})
elif sync_config.mode == "server-authoritative":
if not existing:
new_bm = Bookmark(
id=bm.id,
url=bm.url,
title=bm.title,
description=bm.description,
notes=bm.notes,
tags=bm.tags,
favicon_url=bm.favicon_url,
path=bm.path,
visit_count=bm.visit_count,
is_bookmarked=bm.is_bookmarked,
)
db.add(new_bm)
actions.append({"type": "create", "link_id": bm.id})
if sync_config.deletions_enabled:
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})
db.commit()
return SyncResponse(actions=actions, synced_count=len(actions))
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/", response_model=SyncResponse)
async def sync(
config: SyncConfig,
browser_bookmarks: List[BookmarkData],
server_bookmarks: List[Dict] = Depends(mock_get_server_bookmarks)
):
"""
Sync bookmarks between browser and server.
Mode options:
- bi-directional: Push both ways
- browser-authoritative: Browser overwrites server
- server-authoritative: Download from server only
"""
response = mock_apply_sync(config, [b.model_dump() for b in browser_bookmarks])
return response
async def sync(config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]):
return apply_sync(config, browser_bookmarks)
@router.get("/collections")
@router.get("/collections", response_model=List[dict])
async def list_collections():
"""List user's collections."""
return [
{
"id": str(uuid.uuid4()),
"name": "Work Links",
"description": "Work-related links",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False
}
]
db = get_session()
try:
from models.base import Collection
collections = db.query(Collection).all()
return [c.to_dict() for c in collections]
finally:
db.close()
@router.get("/collections/{collection_id}")
@router.get("/collections/{collection_id}", response_model=dict)
async def get_collection(collection_id: str):
"""Get collection details."""
return {
"id": collection_id,
"name": "Work Links",
"description": "Work-related links",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False
}
db = get_session()
try:
from models.base import Collection
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return collection.to_dict()
finally:
db.close()
@router.post("/collections/{collection_id}/add-links")
async def add_links_to_collection(
collection_id: str,
bookmark_ids: List[str]
):
"""Add links to static collection."""
return {
"collection_id": collection_id,
"added_count": len(bookmark_ids),
"message": "Links added successfully"
}
@router.post("/collections/{collection_id}/add-links", response_model=dict)
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()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
if collection.query_type != "static":
raise HTTPException(status_code=400, detail="Can only add links to static collections")
added = 0
for bid in bookmark_ids:
existing = (
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == collection_id,
CollectionBookmark.bookmark_id == bid,
)
.first()
)
if not existing:
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=bid))
added += 1
db.commit()
return {"collection_id": collection_id, "added_count": added, "message": "Links added successfully"}
finally:
db.close()
@router.delete("/collections/{collection_id}")
@router.delete("/collections/{collection_id}", response_model=dict)
async def delete_collection(collection_id: str):
"""Delete collection."""
return {"message": "Collection deleted successfully"}
db = get_session()
try:
from models.base import Collection, CollectionBookmark
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
db.query(CollectionBookmark).filter(
CollectionBookmark.collection_id == collection_id
).delete()
db.delete(collection)
db.commit()
return {"message": "Collection deleted successfully"}
finally:
db.close()

View File

@@ -2,187 +2,164 @@
LinkSyncServer - Tag Management Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
import logging
import uuid
from typing import List, Optional
from models.base import Base, Tag, Bookmark, get_engine
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from models.base import Bookmark, Tag, get_session
router = APIRouter(prefix="/api/tags", tags=["Tags"])
logger = logging.getLogger(__name__)
class TagCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=100)
color: Optional[str] = Field(None, max_length=7)
description: Optional[str] = Field(None, max_length=500)
class TagUpdate(BaseModel):
name: Optional[str] = Field(None)
color: Optional[str] = Field(None)
name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, max_length=7)
description: Optional[str] = Field(None, max_length=500)
class TagResponse(BaseModel):
id: str
name: str
color: Optional[str]
created_at: str
updated_at: str
def get_db_session():
"""Get database session."""
@router.get("/", response_model=List[dict])
async def list_tags(
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
search: Optional[str] = Query(None),
):
db = get_session()
try:
return Session(get_engine())
except Exception:
return None
def get_current_user(request):
"""Get current authenticated user."""
SECRET_KEY = None
try:
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
import jwt
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return {"username": payload.get("sub"), "id": payload.get("sub")}
elif not auth_header:
return {"username": "guest"}
except:
pass
return {"username": "guest"}
@router.get("/", response_model=List[TagResponse])
async def list_tags(page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200)):
"""List tags with pagination."""
db = get_db_session()
if not db:
return []
count = db.query(Tag).count()
tags = db.query(Tag).order_by(Tag.name).offset((page - 1) * per_page).limit(per_page).all()
return tags
query = db.query(Tag)
if search:
query = query.filter(Tag.name.ilike(f"%{search}%"))
tags = query.order_by(Tag.name).offset((page - 1) * per_page).limit(per_page).all()
return [t.to_dict() for t in tags]
finally:
db.close()
@router.get("/count", response_model=dict)
async def tag_count():
"""Get total tag count."""
db = get_db_session()
if not db:
return {"count": 0}
return {"count": db.query(Tag).count()}
db = get_session()
try:
return {"count": db.query(Tag).count()}
finally:
db.close()
@router.get("/{tag_id}", response_model=TagResponse)
@router.get("/{tag_id}", response_model=dict)
async def get_tag(tag_id: str):
"""Get tag by ID."""
db = get_db_session()
if not db:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag.to_dict()
finally:
db.close()
@router.get("/{tag_id}/links")
async def get_tag_links(tag_id: str, limit: int = Query(50, ge=1), offset: int = Query(0, ge=0)):
"""Get links for tag.""""
db = get_db_session()
if not db:
return []
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
links = db.query(Bookmark).join(Tag).filter(Tag.id == tag_id).limit(limit).offset(offset).all()
return links
@router.get("/name/{tag_name}", response_model=dict)
async def get_tag_by_name(tag_name: str):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.name == tag_name).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag.to_dict()
finally:
db.close()
@router.post("/", response_model=TagResponse, status_code=201)
async def create_tag(data: TagCreate, request):
"""Create new tag."""
db = get_db_session()
if not db:
raise HTTPException(status_code=500, detail="Database unavailable")
tag = Tag(
id=f"tag-{uuid.uuid4()[:8]}",
name=data.name,
color=data.color
)
db.add(tag)
db.commit()
db.refresh(tag)
return tag
@router.get("/{tag_id}/links", response_model=List[dict])
async def get_tag_links(
tag_id: str,
limit: int = Query(50, ge=1),
offset: int = Query(0, ge=0),
):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
bookmarks = (
db.query(Bookmark)
.filter(Bookmark.tags.contains(tag.name))
.order_by(Bookmark.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return [b.to_dict() for b in bookmarks]
finally:
db.close()
@router.put("/{tag_id}", response_model=TagResponse)
@router.post("/", response_model=dict, status_code=201)
async def create_tag(data: TagCreate):
db = get_session()
try:
existing = db.query(Tag).filter(Tag.name == data.name).first()
if existing:
raise HTTPException(status_code=400, detail="Tag already exists")
tag = Tag(
id=str(uuid.uuid4()),
name=data.name,
color=data.color,
description=data.description,
)
db.add(tag)
db.commit()
db.refresh(tag)
return tag.to_dict()
finally:
db.close()
@router.put("/{tag_id}", response_model=dict)
async def update_tag(tag_id: str, data: TagUpdate):
"""Update tag."""
db = get_db_session()
if not db:
raise HTTPException(status_code=500, detail="Database unavailable")
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
for field_name in ["name", "color"]:
if field_name in data.dict() and data.dict()[field_name] is not None:
setattr(tag, field_name, data.dict()[field_name])
db.commit()
db.refresh(tag)
return tag
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tag, field, value)
db.commit()
db.refresh(tag)
return tag.to_dict()
finally:
db.close()
@router.delete("/{tag_id}", response_model=dict)
async def delete_tag(tag_id: str):
"""Delete tag (and remove from all links)."""
db = get_db_session()
if not db:
raise HTTPException(status_code=500, detail="Database unavailable")
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
# Remove tag from all bookmarks
bookmarks = db.query(Bookmark).filter(Bookmark.tags.contains(tag.name)).all()
for bookmark in bookmarks:
bookmark.tags = [t for t in bookmark.tags if t[0] != tag_id]
db.delete(tag)
db.commit()
return {"message": f"Tag '{tag.name}' deleted and removed from all links"}
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
tag_name = tag.name
bookmarks = db.query(Bookmark).filter(Bookmark.tags.contains(tag_name)).all()
for bookmark in bookmarks:
bookmark.tags = [t for t in (bookmark.tags or []) if t != tag_name]
db.add(bookmark)
db.delete(tag)
db.commit()
return {"message": f"Tag '{tag_name}' deleted and removed from all links"}
finally:
db.close()

View File

@@ -0,0 +1,23 @@
"""
LinkSyncServer - API Router Aggregator
"""
from fastapi import APIRouter
from api.endpoints.auth import router as auth_router
from api.endpoints.links import router as links_router
from api.endpoints.collections import router as collections_router
from api.endpoints.queries import router as queries_router
from api.endpoints.sync import router as sync_router
from api.endpoints.tags import router as tags_router
from api.endpoints.admin import router as admin_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(links_router)
router.include_router(collections_router)
router.include_router(queries_router)
router.include_router(sync_router)
router.include_router(tags_router)
router.include_router(admin_router)

View File

@@ -1,151 +0,0 @@
"""
LinkSyncServer - Sync Endpoint for Browser Extension
"""
from fastapi import APIRouter, HTTPException, status
from typing import List, Dict
import jwt
import logging
from datetime import datetime
import json
from models.base import Bookmark, Collection, get_engine
from api.parsers.bookmarks import BookmarkParser
from api.parsers.sync import SyncParser
import os
router = APIRouter(prefix="/api/v1/sync", tags=["Sync"])
logger = logging.getLogger(__name__)
# Get database and secrets
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///links.db")
SECRET_KEY = os.environ.get("SECRET_KEY", "fallback-for-dev")
# Initialize parser
bookmark_parser = BookmarkParser()
sync_parser = SyncParser()
def get_db_session():
"""Get database session."""
from sqlalchemy.pool import StaticPool
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
engine = create_engine(
DATABASE_URL,
connect_args={'check_same_thread': False}
)
return Session(engine)
def validate_request_token(request_token: str) -> Dict:
"""
Validate sync request token.
Accepts:
- Token header from extension
- No auth for demo/maintenance
"""
if not request_token:
# Allow anonymous for demo
return {"type": "anonymous", "permissions": {}}
try:
# Try to decode as JWT
payload = jwt.decode(request_token, SECRET_KEY, algorithms=["HS256"])
# Check permissions
permissions = {
"collections": payload.get("permissions", {}).get("collections", []),
"bookmarks": payload.get("permissions", {}).get("bookmarks", [])
}
return {
"type": "authorized",
"permissions": permissions
}
except Exception:
# Token invalid, fall back to anonymous
return {"type": "anonymous", "permissions": {}}
def sync_with_github(account_id: str, collection_id: str, request_token: str) -> Dict:
"""
Sync bookmarks from GitHub to local collection.
Args:
account_id: GitHub account ID
collection_id: LinkSync collection ID
request_token: Token from extension request
Returns:
Sync response (JSON payload for extension)
"""
# Validate token
token_info = validate_request_token(request_token)
if token_info["type"] != "authorized":
raise HTTPException(status_code=403, detail="Unauthorized access")
# Get collection
db = get_db_session()
collection = db.query(Collection).filter(Collection.id == collection_id).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
# Make request to GitHub API (using library or requests)
try:
# GitHub API v3
# GET /users/{user_id}/starred
# Response: list of starred repositories and Gists (links)
github_api_base = "https://api.github.com"
starred_response = requests.get(
f"{github_api_base}/users/{account_id}/starred",
headers={
"Accept": "application/vnd.github.v3+json"
}
)
if starred_response.status_code != 200:
raise HTTPException(status_code=502, detail="Failed to fetch GitHub data")
github_links = starred_response.json()
# Parse GitHub data
github_bookmarks = sync_parser.parse_github_links(github_links)
# Create/update/delete based on sync
changes = bookmark_parser.parse_sync(
github_bookmarks, collection_id
)
# Commit changes
db.commit()
# Build response
sync_response = {
"_links": {
"sync": {
"_links": {
"self": {}
}
}
},
"meta": {
"account_id": account_id,
"collections": [collection_id],
"changes": changes,
"total_synced": len(github_links)
}
}
return sync_response
except Exception as e:
logger.error(f"Sync error: {e}")
raise HTTPException(status_code=500, detail=str(e))