Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation

This commit is contained in:
DavidSaylor
2026-05-11 17:37:10 -05:00
parent ad0b12b452
commit aed69afdfd
691 changed files with 181874 additions and 28 deletions

View File

@@ -0,0 +1,152 @@
"""
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
from datetime import datetime, timedelta
import jwt
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
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."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_user_from_token(token: str):
"""Get user from JWT token."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[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"}
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@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"
}
}
@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:
token = create_access_token(
data={"sub": admin_username, "type": "access"}
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": admin_username, "role": "admin"}
}
# 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"}
}
@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.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}
@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."""
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")

View File

@@ -0,0 +1,169 @@
"""
LinkSyncServer - Collection CRUD Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
router = APIRouter(prefix="/api/collections", tags=["Collections"])
class CollectionCreate(BaseModel):
name: str
description: Optional[str] = None
query_type: str # "static" or "dynamic"
query_expression: Optional[dict] = None
is_public: bool = False
class CollectionUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
query_type: Optional[str] = None
query_expression: Optional[dict] = None
is_public: Optional[bool] = 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
def mock_create_collection(data: CollectionCreate) -> CollectionResponse:
"""Create collection (mock implementation)."""
return {
"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,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
def mock_get_collections() -> List[CollectionResponse]:
"""Get all collections (mock implementation)."""
return [
{
"id": str(uuid.uuid4()),
"name": "Work Links",
"description": "Links for work use",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
]
def mock_get_collection(collection_id: str) -> CollectionResponse | None:
"""Get collection by ID (mock implementation)."""
if collection_id == "mock-id":
return {
"id": "mock-id",
"name": "Work Links",
"description": "Links for work use",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
"is_public": False,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
return None
def mock_update_collection(collection_id: str, data: CollectionUpdate) -> CollectionResponse | None:
"""Update collection."""
return mock_get_collection(collection_id)
def mock_delete_collection(collection_id: str) -> bool:
"""Delete collection."""
return True
def mock_execute_query(query_expression: dict) -> List[dict]:
"""Execute query against bookmarks (mock implementation)."""
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com/work",
"title": "Work Example",
"description": "An example",
"notes": "",
"tags": ["work"],
"favicon_url": None,
"path": "/Work",
"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.get("/", response_model=List[CollectionResponse])
async def list_collections():
"""List all collections."""
return mock_get_collections()
@router.get("/{collection_id}", response_model=CollectionResponse)
async def get_collection(collection_id: str):
"""Get collection by ID."""
collection = mock_get_collection(collection_id)
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return collection
@router.post("/", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED)
async def create_collection(data: CollectionCreate):
"""Create new collection."""
collection = mock_create_collection(data)
return collection
@router.put("/{collection_id}", response_model=CollectionResponse)
async def update_collection(
collection_id: str,
data: CollectionUpdate
):
"""Update collection."""
collection = mock_update_collection(collection_id, data)
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return collection
@router.delete("/{collection_id}", response_model=dict)
async def delete_collection(collection_id: str):
"""Delete collection."""
success = mock_delete_collection(collection_id)
if not success:
raise HTTPException(status_code=404, detail="Collection not found")
return {"message": "Collection deleted successfully"}
@router.post("/{collection_id}/refresh", response_model=dict)
async def refresh_collection(collection_id: str):
"""Refresh dynamic collection (re-evaluate query)."""
return {"message": "Collection refreshed successfully"}
@router.post("/execute", response_model=List[dict])
async def execute_query(query_expression: dict):
"""Execute query and return result set."""
return mock_execute_query(query_expression)

View File

@@ -0,0 +1,175 @@
"""
LinkSyncServer - Link CRUD Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
router = APIRouter(prefix="/api/links", tags=["Links"])
class BookmarkCreate(BaseModel):
url: str
title: str
description: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
favicon_url: Optional[str] = None
path: Optional[str] = None
visit_count: int = 0
is_bookmarked: bool = False
class BookmarkUpdate(BaseModel):
url: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
favicon_url: Optional[str] = None
path: Optional[str] = None
visit_count: Optional[int] = None
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]
def get_db():
"""Get database session."""
from models.base import get_engine
db = get_engine()
return db
def mock_create_bookmark(data: BookmarkCreate) -> dict:
"""Create bookmark (mock implementation for demo)."""
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,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": data.visit_count,
"is_bookmarked": data.is_bookmarked,
"source_set_id": None
}
return bookmark
def mock_get_bookmarks() -> List[dict]:
"""Get all bookmarks (mock implementation)."""
return [
{
"id": str(uuid.uuid4()),
"url": "https://example.com",
"title": "Example",
"description": "An example website",
"notes": "",
"tags": ["example", "demo"],
"favicon_url": None,
"path": "/Home",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
]
def mock_get_bookmark(bookmark_id: str) -> dict | None:
"""Get single bookmark by ID."""
# Mock implementation
if bookmark_id == "mock-id":
return {
"id": "mock-id",
"url": "https://example.com",
"title": "Example",
"description": "An example website",
"notes": "",
"tags": ["example", "demo"],
"favicon_url": None,
"path": "/Home",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
return None
def mock_update_bookmark(bookmark_id: str, data: BookmarkUpdate) -> dict | None:
"""Update bookmark."""
# Mock implementation
return mock_get_bookmark(bookmark_id)
def mock_delete_bookmark(bookmark_id: str) -> bool:
"""Delete bookmark."""
return True
@router.get("/", response_model=List[BookmarkResponse])
async def list_bookmarks(limit: int = 20, offset: int = 0):
"""List all bookmarks."""
bookmarks = mock_get_bookmarks()
return bookmarks[offset:offset + limit]
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
async def get_bookmark(bookmark_id: str):
"""Get bookmark by ID."""
bookmark = mock_get_bookmark(bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark
@router.post("/", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED)
async def create_bookmark(data: BookmarkCreate):
"""Create new bookmark."""
bookmark = mock_create_bookmark(data)
return bookmark
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
async def update_bookmark(
bookmark_id: str,
data: BookmarkUpdate
):
"""Update bookmark."""
bookmark = mock_update_bookmark(bookmark_id, data)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark
@router.delete("/{bookmark_id}", response_model=dict)
async def delete_bookmark(bookmark_id: str):
"""Delete bookmark."""
success = mock_delete_bookmark(bookmark_id)
if not success:
raise HTTPException(status_code=404, detail="Bookmark not found")
return {"message": "Bookmark deleted successfully"}

View File

@@ -0,0 +1,253 @@
"""
LinkSyncServer - Query Engine
"""
from fastapi import APIRouter, HTTPException
from typing import List, Optional, Dict, Any
import re
import uuid
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
}
@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
}
]
@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"
}

View File

@@ -0,0 +1,150 @@
"""
LinkSyncServer - Sync Endpoint for Browser Extension
"""
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any
import uuid
router = APIRouter(prefix="/api/sync", tags=["Sync"])
class SyncConfig(BaseModel):
mode: str # "bi-directional", "browser-authoritative", "server-authoritative"
deletions_enabled: bool = False
class BookmarkData(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
class SyncResponse(BaseModel):
actions: List[Dict[str, Any]]
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 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
}
]
@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
@router.get("/collections")
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
}
]
@router.get("/collections/{collection_id}")
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
}
@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.delete("/collections/{collection_id}")
async def delete_collection(collection_id: str):
"""Delete collection."""
return {"message": "Collection deleted successfully"}