Files
DavidSaylor fe4cbc3537 feat: add web UI, query engine, session management, and 20 E2E tests
- Web UI: login, dashboard, links CRUD, collections, API keys, admin pages
- Query engine: AND/OR/XOR with field filters, tag search, preview endpoint
- Session management: token expiry detection, 401 interceptor, expiry banner
- Links search: tags included, multi-word AND, query mode with set operations
- Collections: static/dynamic, query builder with preview, public tree view
- Save as Collection: convert search results (static) or query (dynamic)
- Dashboard stats: resilient loading with allSettled pattern
- Login page: redesigned with public collections tree view
- Bug fix: query executor None fields crash (notes/description/url/title)
- E2E tests: 20 Playwright tests covering all critical user flows
- All 104 tests passing (84 unit/integration + 20 E2E)
2026-05-22 07:46:53 -05:00

10 KiB

LinkSyncServer - Design Documentation

Architecture Overview

LinkSyncServer is a Python/FastAPI web application with PostgreSQL as the database, designed for bookmark management with advanced collection and query capabilities.

Tech Stack

Component Technology
Framework FastAPI
ORM SQLAlchemy
Authentication JWT (PyJWT)
Database PostgreSQL 15+
Templates Jinja2
Static Files Native static serving
Containerization Docker

Directory Structure

LinkSyncServer/
├── README.md
├── TODOs.txt
├── design.md
├── tasks.md
├── AGENTS.md
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── pyproject.toml
├── app.py
├── config/
│   ├── settings.py
│   └── schema.sql
├── api/
│   ├── __init__.py
│   ├── routes.py
│   ├── endpoints/
│   │   ├── auth.py
│   │   ├── links.py
│   │   ├── collections.py
│   │   └── queries.py
│   ├── parsers/
│   │   ├── __init__.py
│   │   └── query_parser.py
│   └── serializers/
│       ├── __init__.py
│       └── schemas.py
├── models/
│   ├── __init__.py
│   ├── base.py
│   ├── user.py
│   ├── link.py
│   ├── collection.py
│   └── tag.py
├── queries/
│   ├── __init__.py
│   ├── ast.py
│   └── executor.py
├── templates/
│   ├── base.html
│   ├── layout.html
│   ├── links/
│   │   ├── list.html
│   │   ├── detail.html
│   │   └── create.html
│   ├── collections/
│   │   ├── list.html
│   │   ├── detail.html
│   │   ├── create.html
│   │   └── edit.html
│   └── auth/
│       ├── login.html
│       ├── register.html
│       └── forgot_password.html
├── static/
│   ├── css/
│   │   ├── main.css
│   │   └── print.css
│   ├── js/
│   │   ├── main.js
│   │   └── api.js
│   └── images/
└── tests/
    ├── __init__.py
    ├── conftest.py
    ├── test_auth.py
    ├── test_links.py
    └── test_collections.py

Data Models

User

class User(Base):
    id: UUID
    username: str (unique, indexed)
    email: str (unique, indexed)
    password_hash: str
    role: Enum('admin', 'user')
    is_active: bool
    created_at: datetime
    updated_at: datetime
class Link(Base):
    id: UUID
    url: str (indexed)
    title: str
    description: str | None
    notes: str | None
    tags: List[UUID] (FK to tags)
    favicon_url: str | None
    path: str (folder structure)
    created_at: datetime
    updated_at: datetime
    visit_count: int
    is_bookmarked: bool
    source_set_id: UUID | None (FK to collections)
    user_id: UUID (FK, nullable for shared links)

Collection

class Collection(Base):
    id: UUID
    name: str (unique per user)
    description: str | None
    query_type: Enum('static', 'dynamic')
    query_expression: str | None  # SQL-like query string
    links: List[UUID]  # For static collections only
    is_public: bool
    created_by: UUID (FK to users)
    created_at: datetime
    updated_at: datetime

Tag

class Tag(Base):
    id: UUID
    name: str (unique)
    color: str | None
    description: str | None
    created_at: datetime
    updated_at: datetime

AuditLog

class AuditLog(Base):
    id: UUID
    user_id: UUID (FK, nullable for system events)
    action: str
    entity_type: str
    entity_id: UUID
    old_value: str | None
    new_value: str | None
    ip_address: str
    created_at: datetime

Query Engine Design

Query Syntax

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

Parser Architecture

Input String
    ↓
Tokenize
    ↓
Build AST Node Tree
    ↓
Validate AST
    ↓
Serialize to JSON (for storage)

AST Node Types

Node Type Description
TermSet Tuple of search terms: ('term1', 'term2')
TagFilter Tag-based filter: tagA
FieldFilter Field value filter: url:example.com
AND Set intersection
OR Set union
XOR Set difference
NOT Negation

Executor Flow

1. Parse query expression
2. Validate AST
3. Build SQL from AST
4. Execute against PostgreSQL
5. Return result set
6. Serialize for client

PostgreSQL full-text search enabled via:

CREATE INDEX links_fts_idx ON links USING GIN (to_tsvector('english', url || ' ' || title || ' ' || description || ' ' || notes));

Query terms converted to tsquery and matched.

API Design

Authentication Flow

1. POST /api/auth/register/ - Create new account
2. POST /api/auth/login/ - Get JWT token
3. Include Authorization header in all API requests
4. Token validated per request
Method Endpoint Description
GET /api/links/ List links (paginated)
GET /api/links/{id}/ Get link details
POST /api/links/ Create link
PUT /api/links/{id}/ Update link
DELETE /api/links/{id}/ Delete link

Collection Endpoints

Method Endpoint Description
GET /api/collections/ List collections
GET /api/collections/{id}/ Get collection
POST /api/collections/ Create collection
PUT /api/collections/{id}/ Update collection
DELETE /api/collections/{id}/ Delete collection
POST /api/collections/{id}/refresh/ Re-evaluate dynamic

Query Execution Endpoint

POST /api/queries/execute/
{
    "expression": "('work', 'dev') OR tag:work",
    "static_collections": [...]
}
→ Returns filtered link list

Security Design

Password Storage

  • bcrypt hashing with cost factor 12
  • Passwords never logged or exposed

JWT Tokens

  • HS256 algorithm
  • 24-hour expiration
  • Refresh token pattern for long sessions

Rate Limiting

  • 100 requests per minute per IP
  • 10 login attempts per hour per IP

CORS

  • Configurable origin whitelist
  • Credentials allowed for extension

Docker Compose Design

Services

Service Image Purpose
web built from Dockerfile FastAPI app
db postgres:15-alpine PostgreSQL database

Environment Variables

DATABASE_URL=postgresql://linksync:password@db:5432/linksync
SECRET_KEY=<generated>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<password>
DEBUG=False
HOST=0.0.0.0
PORT=5000
CORS_ORIGINS=http://localhost:5555

Health Checks

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
  interval: 30s
  timeout: 10s
  retries: 3

Sync Protocol Design

Sync Endpoint

POST /api/sync/

Request:
{
    "type": "bi-directional|browser-authoritative|server-authoritative",
    "deletions_enabled": true,
    "links": [
        {
            "id": "uuid",
            "url": "https://...",
            "title": "...",
            ... all fields
        }
    ]
}

Response:
{
    "actions": [
        {"type": "create", "link_id": "..."},
        {"type": "update", "link_id": "..."},
        {"type": "delete", "link_id": "..."}
    ]
}

Conflict Resolution

Priority based on sync mode:

  1. Bi-directional: Keep both versions, merge metadata
  2. Browser Authoritative: Overwrite with browser data
  3. Server Authoritative: Download only, no overwrites

Template Design

Layout Components

<!-- base.html -->
<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}LinkSync{% endblock %}</title>
    <link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
    <nav>{% block nav %}{% endblock %}</nav>
    <main>{% block content %}{% endblock %}</main>
    <footer>{% block footer %}{% endblock %}</footer>
    <script src="/static/js/main.js"></script>
</body>
</html>

Responsive Design

  • Mobile-first CSS
  • Breakpoints: 768px, 1024px
  • Touch-friendly UI controls

UI/UX Design

Session Management

Token Lifecycle

  1. Login - stores JWT in localStorage with user info
  2. Page load - checks token expiry via JWT exp claim
  3. API calls - validates expiry before each request
  4. 401 response - clears storage and redirects to /login?expired=1
  5. Expiry warning - shows banner if token expires in <2 minutes

Implementation

  • main.js: isTokenExpired(), getTokenExpirySeconds(), redirectToLogin()
  • apiFetch(): Pre-flight expiry check + 401 interceptor
  • dashboard.js: Expiry warning banner on page load
  • login.html: Shows expiry message when redirected with ?expired=1

Search Architecture

  • Input: Free text with space-separated terms
  • Behavior: Each term must match at least one field (AND logic)
  • Fields searched: title, URL, description, notes, tags
  • Backend: Multiple OR clauses per term, combined with AND between terms
  • Input: Query expression with operators
  • Syntax: term1 AND term2 OR term3 NOT term4
  • Operators: AND (intersection), OR (union), XOR (difference), NOT (negation)
  • Parentheses: Grouping for precedence control
  • Field filters: tag:value, url:value, title:value, description:value, path:value
  • Term sets: (term1, term2) matches any term in the set

Collection Query Builder

Dynamic Collection Modal

  • Type selector toggles query section visibility
  • Query input with syntax hint
  • Preview button fetches matching links via /api/queries/preview
  • Results display shows count and first 10 matching links
  • Save stores query_expression: { expression: "..." } for dynamic collections
  • Static mode: Saves current result set link IDs
  • Dynamic mode: Saves the active query expression
  • Modal pre-fills name based on current search/query
  • Public toggle controls collection visibility

Error Handling

  • API errors: Structured error responses with user-friendly messages
  • Network errors: Graceful fallbacks with retry options
  • Form validation: Client-side validation before submission
  • Server errors: Logged with stack traces in debug mode