diff --git a/Dockerfile b/Dockerfile index 1911865..d64a6fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,10 @@ RUN apt update && apt install --no-install-recommends --no-install-suggests -y \ WORKDIR /app +COPY pyproject.toml uv.lock README.md . + +RUN uv sync --locked --no-dev --no-install-project --no-cache + COPY . . RUN uv sync --locked --no-dev --no-editable --no-cache diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e030b90 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,90 @@ +# Alembic configuration file for nextcloud-mcp-server + +[alembic] +# Path to migration scripts +script_location = alembic + +# Template used to generate migration file names +# Default: %%(rev)s_%%(slug)s +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# Timezone for migration timestamps +# Default: utc +timezone = utc + +# Max length of characters to apply to the "slug" field +# Default: 40 +# truncate_slug_length = 40 + +# Set to 'true' to run the environment during the 'revision' command +# Default: false +# revision_environment = false + +# Set to 'true' to allow .pyc and .pyo files without a source .py file +# Default: false +# sourceless = false + +# Version location specification +# Supports single or multiple directories +version_locations = alembic/versions + +# Path separator for version locations (required to suppress deprecation warning) +# Use os (for cross-platform compatibility) +path_separator = os + +# Set to 'true' to search source files recursively in each "version_locations" directory +# Default: false +# recursive_version_locations = false + +# Output encoding used when revision files are written +# Default: utf-8 +# output_encoding = utf-8 + +# Database URL - can be overridden by: +# 1. Passing -x database_url=... to alembic commands +# 2. Setting in environment via get_database_url() in env.py +# Default: sqlite:///app/data/tokens.db +sqlalchemy.url = sqlite+aiosqlite:////app/data/tokens.db + +[post_write_hooks] +# Post-write hooks allow you to run scripts after generating migration files +# Example: format migrations with ruff +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = format REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..27072cf --- /dev/null +++ b/alembic/README @@ -0,0 +1,71 @@ +Database Migrations for nextcloud-mcp-server +============================================ + +This directory contains Alembic database migrations for the token storage database. + +Structure +--------- +- env.py: Alembic environment configuration +- script.py.mako: Template for generating new migration files +- versions/: Directory containing migration scripts + +Usage +----- +Migrations are managed via the CLI: + + # Upgrade database to latest version + uv run nextcloud-mcp-server db upgrade + + # Show current database version + uv run nextcloud-mcp-server db current + + # Show migration history + uv run nextcloud-mcp-server db history + + # Create a new migration (developers only) + uv run nextcloud-mcp-server db migrate "description of changes" + + # Downgrade database by one version (emergency use only) + uv run nextcloud-mcp-server db downgrade + +Direct Alembic Usage +-------------------- +You can also use Alembic commands directly: + + # Specify database URL via -x flag + uv run alembic -x database_url=sqlite+aiosqlite:////path/to/tokens.db upgrade head + + # Or set in alembic.ini and run + uv run alembic upgrade head + uv run alembic current + uv run alembic history + +Writing Migrations +------------------ +Since we don't use SQLAlchemy models, migrations are written with raw SQL: + + def upgrade() -> None: + op.execute(""" + ALTER TABLE refresh_tokens + ADD COLUMN new_field TEXT + """) + + def downgrade() -> None: + # SQLite doesn't support DROP COLUMN, use table recreation + op.execute(""" + CREATE TABLE refresh_tokens_new AS + SELECT user_id, encrypted_token, ... FROM refresh_tokens + """) + op.execute("DROP TABLE refresh_tokens") + op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens") + +Migration File Naming +--------------------- +Format: YYYYMMDD_HHMM__.py +Example: 20251217_2200_001_initial_schema.py + +Notes +----- +- Migrations run automatically when RefreshTokenStorage.initialize() is called +- Existing databases are automatically stamped with the initial version +- SQLite has limited ALTER TABLE support - complex changes require table recreation diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..4a3fadb --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,128 @@ +"""Alembic environment configuration for nextcloud-mcp-server. + +This module configures how Alembic runs database migrations for the +token storage database. It supports both online and offline migration modes. + +Uses anyio for async operations, consistent with the project's async patterns. +""" + +import logging +from pathlib import Path + +import anyio +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Configure logging +logger = logging.getLogger("alembic.env") + +# This is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# We don't use SQLAlchemy models, so target_metadata is None +# Migrations will be written manually using op.execute() for raw SQL +target_metadata = None + + +def get_database_url() -> str: + """ + Get the database URL from Alembic config or environment. + + The URL can be set in alembic.ini or passed via -x database_url=... + when running Alembic commands. + + Returns: + Database URL (SQLite URL format) + """ + # Check if URL is passed via -x database_url=... + url = context.get_x_argument(as_dictionary=True).get("database_url") + + if not url: + # Fall back to alembic.ini configuration + url = config.get_main_option("sqlalchemy.url") + + if not url: + # Default to /app/data/tokens.db for Docker deployments + db_path = Path("/app/data/tokens.db") + url = f"sqlite+aiosqlite:///{db_path}" + logger.warning( + f"No database URL configured, using default: {url}. " + "Set sqlalchemy.url in alembic.ini or pass -x database_url=..." + ) + + return url + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL and not an Engine, + though an Engine is acceptable here as well. By skipping the + Engine creation we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + This mode is useful for generating SQL scripts without database access. + """ + url = get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Execute migrations within a database connection.""" + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async support. + + In this scenario we create an async Engine and associate + a connection with the context. + """ + # Get database URL and update config + url = get_database_url() + config.set_main_option("sqlalchemy.url", url) + + # Create async engine + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, # Don't pool connections for migrations + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + This function is called from storage.py's initialize() method via + anyio.to_thread.run_sync(), so it always runs in a worker thread + with its own event loop. We can safely use anyio.run() here. + """ + anyio.run(run_async_migrations) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..3525c5f --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + """Apply migration changes to upgrade the database schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Revert migration changes to downgrade the database schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/20251217_2200_001_initial_schema.py b/alembic/versions/20251217_2200_001_initial_schema.py new file mode 100644 index 0000000..3f1110c --- /dev/null +++ b/alembic/versions/20251217_2200_001_initial_schema.py @@ -0,0 +1,185 @@ +"""Initial schema for token storage database + +This migration creates the initial database schema including: +- refresh_tokens: OAuth refresh tokens and user profiles +- audit_logs: Audit trail for security events +- oauth_clients: OAuth client credentials (DCR) +- oauth_sessions: OAuth flow session state (ADR-004 Progressive Consent) +- registered_webhooks: Webhook registration tracking (both OAuth and BasicAuth) +- schema_version: Legacy schema version tracking (deprecated, use alembic_version) + +Revision ID: 001 +Revises: +Create Date: 2025-12-17 22:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create initial database schema.""" + + # Refresh tokens table (OAuth mode only, for background jobs) + op.execute( + """ + CREATE TABLE IF NOT EXISTS refresh_tokens ( + user_id TEXT PRIMARY KEY, + encrypted_token BLOB NOT NULL, + expires_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + -- ADR-004 Progressive Consent fields + flow_type TEXT DEFAULT 'hybrid', + token_audience TEXT DEFAULT 'nextcloud', + provisioned_at INTEGER, + provisioning_client_id TEXT, + scopes TEXT, + -- Browser session profile cache + user_profile TEXT, + profile_cached_at INTEGER + ) + """ + ) + + # Audit logs table (both OAuth and BasicAuth modes) + op.execute( + """ + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + event TEXT NOT NULL, + user_id TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + auth_method TEXT, + hostname TEXT + ) + """ + ) + + # Index on audit logs for efficient queries + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp + ON audit_logs(user_id, timestamp) + """ + ) + + # OAuth client credentials storage (OAuth mode only) + op.execute( + """ + CREATE TABLE IF NOT EXISTS oauth_clients ( + id INTEGER PRIMARY KEY, + client_id TEXT UNIQUE NOT NULL, + encrypted_client_secret BLOB NOT NULL, + client_id_issued_at INTEGER NOT NULL, + client_secret_expires_at INTEGER NOT NULL, + redirect_uris TEXT NOT NULL, + encrypted_registration_access_token BLOB, + registration_client_uri TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + + # OAuth flow sessions (ADR-004 Progressive Consent) + op.execute( + """ + CREATE TABLE IF NOT EXISTS oauth_sessions ( + session_id TEXT PRIMARY KEY, + client_id TEXT, + client_redirect_uri TEXT NOT NULL, + state TEXT, + code_challenge TEXT, + code_challenge_method TEXT, + mcp_authorization_code TEXT UNIQUE, + idp_access_token TEXT, + idp_refresh_token TEXT, + user_id TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + -- ADR-004 Progressive Consent fields + flow_type TEXT DEFAULT 'hybrid', + requested_scopes TEXT, + granted_scopes TEXT, + is_provisioning BOOLEAN DEFAULT FALSE + ) + """ + ) + + # Index for MCP authorization code lookups + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code + ON oauth_sessions(mcp_authorization_code) + """ + ) + + # Legacy schema version tracking table + # NOTE: This is deprecated in favor of Alembic's alembic_version table + # Kept for backward compatibility with pre-Alembic databases + op.execute( + """ + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at REAL NOT NULL + ) + """ + ) + + # Registered webhooks tracking (both BasicAuth and OAuth modes) + op.execute( + """ + CREATE TABLE IF NOT EXISTS registered_webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + webhook_id INTEGER NOT NULL UNIQUE, + preset_id TEXT NOT NULL, + created_at REAL NOT NULL + ) + """ + ) + + # Indexes for efficient webhook queries + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_webhooks_preset + ON registered_webhooks(preset_id) + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_webhooks_created + ON registered_webhooks(created_at) + """ + ) + + +def downgrade() -> None: + """Drop all tables and indexes. + + WARNING: This will destroy all data in the database! + Use with extreme caution. + """ + + # Drop indexes first + op.execute("DROP INDEX IF EXISTS idx_webhooks_created") + op.execute("DROP INDEX IF EXISTS idx_webhooks_preset") + op.execute("DROP INDEX IF EXISTS idx_oauth_sessions_mcp_code") + op.execute("DROP INDEX IF EXISTS idx_audit_user_timestamp") + + # Drop tables + op.execute("DROP TABLE IF EXISTS registered_webhooks") + op.execute("DROP TABLE IF EXISTS schema_version") + op.execute("DROP TABLE IF EXISTS oauth_sessions") + op.execute("DROP TABLE IF EXISTS oauth_clients") + op.execute("DROP TABLE IF EXISTS audit_logs") + op.execute("DROP TABLE IF EXISTS refresh_tokens") diff --git a/docs/database-migrations.md b/docs/database-migrations.md new file mode 100644 index 0000000..dead454 --- /dev/null +++ b/docs/database-migrations.md @@ -0,0 +1,301 @@ +# Database Migrations + +This document describes the database migration system for nextcloud-mcp-server's token storage database. + +## Overview + +The token storage database uses [Alembic](https://alembic.sqlalchemy.org/) for schema versioning and migrations. Alembic provides: + +- **Version Control**: Track schema changes in Git +- **Rollback Support**: Safely downgrade schema if needed +- **Audit Trail**: Migration files serve as schema changelog +- **Automated Upgrades**: Database schema updates automatically on startup + +## Architecture + +### Migration Strategy + +The system handles three scenarios: + +1. **New Database**: Runs migrations from scratch to create all tables +2. **Pre-Alembic Database**: Stamps existing database with initial revision (no changes) +3. **Alembic-Managed Database**: Upgrades to latest version automatically + +### Directory Structure + +``` +nextcloud-mcp-server/ +├── alembic/ # Alembic migrations +│ ├── versions/ # Migration scripts +│ │ └── 20251217_2200_001_initial_schema.py +│ ├── env.py # Alembic environment +│ ├── script.py.mako # Migration template +│ └── README # Migration usage guide +├── alembic.ini # Alembic configuration +└── nextcloud_mcp_server/ + ├── auth/storage.py # Uses migrations on init + └── migrations.py # Migration utilities +``` + +## Usage + +### Automatic Migration on Startup + +Migrations run automatically when the server starts: + +```bash +uv run nextcloud-mcp-server +``` + +The `RefreshTokenStorage.initialize()` method: +1. Checks if database is Alembic-managed +2. Stamps pre-Alembic databases with initial revision +3. Upgrades to latest version + +### Manual Migration Commands + +```bash +# Show current database version +uv run nextcloud-mcp-server db current + +# Upgrade database to latest version +uv run nextcloud-mcp-server db upgrade + +# Show migration history +uv run nextcloud-mcp-server db history + +# Downgrade by one version (emergency use only) +uv run nextcloud-mcp-server db downgrade + +# Specify custom database path +uv run nextcloud-mcp-server db current -d /path/to/tokens.db +``` + +### Environment Variables + +- `TOKEN_STORAGE_DB`: Path to database file (default: `/app/data/tokens.db`) + +## Creating Migrations (Developers) + +### Step 1: Create Migration File + +```bash +uv run nextcloud-mcp-server db migrate "add user preferences table" +``` + +This creates a new migration file in `alembic/versions/` with empty `upgrade()` and `downgrade()` functions. + +### Step 2: Write Migration SQL + +Since we don't use SQLAlchemy models, write raw SQL: + +```python +def upgrade() -> None: + """Add user preferences table.""" + op.execute(""" + CREATE TABLE user_preferences ( + user_id TEXT PRIMARY KEY, + theme TEXT DEFAULT 'light', + language TEXT DEFAULT 'en', + created_at INTEGER NOT NULL + ) + """) + + op.execute(""" + CREATE INDEX idx_user_preferences_user_id + ON user_preferences(user_id) + """) + + +def downgrade() -> None: + """Remove user preferences table.""" + op.execute("DROP INDEX IF EXISTS idx_user_preferences_user_id") + op.execute("DROP TABLE IF EXISTS user_preferences") +``` + +### Step 3: Test Migration + +```bash +# Test upgrade +uv run nextcloud-mcp-server db upgrade -d /tmp/test.db + +# Verify schema +sqlite3 /tmp/test.db ".schema" + +# Test downgrade +uv run nextcloud-mcp-server db downgrade -d /tmp/test.db + +# Verify removal +sqlite3 /tmp/test.db ".schema" +``` + +### Step 4: Commit Migration + +```bash +git add alembic/versions/YYYYMMDD_HHMM_XXX_description.py +git commit -m "feat: add user preferences table migration" +``` + +## SQLite Limitations + +SQLite has limited `ALTER TABLE` support: + +### Supported Operations + +- ✅ Add columns: `ALTER TABLE table ADD COLUMN ...` +- ✅ Rename table: `ALTER TABLE old RENAME TO new` +- ✅ Rename column: `ALTER TABLE table RENAME COLUMN old TO new` (SQLite 3.25+) + +### Unsupported Operations (Requires Table Recreation) + +- ❌ Drop column +- ❌ Change column type +- ❌ Add constraints to existing columns + +### Table Recreation Pattern + +For complex schema changes: + +```python +def upgrade() -> None: + # Create new table with desired schema + op.execute(""" + CREATE TABLE refresh_tokens_new ( + user_id TEXT PRIMARY KEY, + encrypted_token BLOB NOT NULL, + new_field TEXT, -- New column + expires_at INTEGER, + created_at INTEGER NOT NULL + ) + """) + + # Copy data from old table + op.execute(""" + INSERT INTO refresh_tokens_new + (user_id, encrypted_token, expires_at, created_at) + SELECT user_id, encrypted_token, expires_at, created_at + FROM refresh_tokens + """) + + # Drop old table and rename new table + op.execute("DROP TABLE refresh_tokens") + op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens") + + # Recreate indexes + op.execute("CREATE INDEX idx_user_id ON refresh_tokens(user_id)") +``` + +## Best Practices + +### Naming Conventions + +- **Migrations**: `YYYYMMDD_HHMM_XXX_description.py` +- **Revision IDs**: Sequential numbers (`001`, `002`, `003`) +- **Descriptions**: Imperative mood ("add table", "remove column") + +### Migration Guidelines + +1. **Test Thoroughly**: Test both upgrade and downgrade paths +2. **Preserve Data**: Ensure data migration logic is correct +3. **Document Changes**: Add comments explaining complex operations +4. **Small Changes**: One logical change per migration +5. **No Breaking Changes**: Maintain backward compatibility when possible + +### Downgrade Considerations + +- **Data Loss**: Downgrade may lose data (dropped columns, tables) +- **Confirmation**: Downgrade command requires explicit confirmation +- **Testing**: Always test downgrade path before deploying +- **Emergency Only**: Use downgrades only for critical rollbacks + +## Backward Compatibility + +### Pre-Alembic Databases + +Existing databases created before Alembic integration are automatically detected and stamped with revision `001`: + +1. Server detects no `alembic_version` table +2. Checks if `refresh_tokens` table exists +3. If yes, stamps database with `001` (no schema changes) +4. Future updates use normal migration path + +### Migration Path + +``` +Pre-Alembic DB → Stamp(001) → Upgrade(002) → Upgrade(003) → ... +New DB → Migrate(001) → Upgrade(002) → Upgrade(003) → ... +``` + +## Troubleshooting + +### Migration Fails + +```bash +# Check current state +uv run nextcloud-mcp-server db current -d /path/to/tokens.db + +# View migration history +uv run nextcloud-mcp-server db history -d /path/to/tokens.db + +# Manually inspect database +sqlite3 /path/to/tokens.db ".schema" +``` + +### Reset to Initial State + +**WARNING: This destroys all data!** + +```bash +# Downgrade to base (empty database) +uv run nextcloud-mcp-server db downgrade -d /path/to/tokens.db --revision base + +# Upgrade to latest +uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db +``` + +### Corrupted Migration State + +If `alembic_version` table is corrupted: + +```bash +# Manually fix via SQL +sqlite3 /path/to/tokens.db +> DELETE FROM alembic_version; +> INSERT INTO alembic_version (version_num) VALUES ('001'); +> .quit + +# Verify and upgrade +uv run nextcloud-mcp-server db current -d /path/to/tokens.db +uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db +``` + +## CI/CD Integration + +### Pre-Deployment + +```bash +# Run migrations in test environment +export TOKEN_STORAGE_DB=/app/data/tokens.db +uv run nextcloud-mcp-server db upgrade + +# Verify current version +uv run nextcloud-mcp-server db current +``` + +### Docker Deployment + +Migrations run automatically on container startup via `RefreshTokenStorage.initialize()`. + +### Rollback Plan + +1. Stop application +2. Backup database: `cp tokens.db tokens.db.backup` +3. Downgrade: `uv run nextcloud-mcp-server db downgrade --revision XXX` +4. Deploy previous application version +5. Restart application + +## References + +- [Alembic Documentation](https://alembic.sqlalchemy.org/) +- [SQLite ALTER TABLE Limitations](https://www.sqlite.org/lang_altertable.html) +- [ADR-004: Progressive Consent](./ADR-004-progressive-consent.md) (migration 001) diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index 4329229..00ba141 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -873,6 +873,12 @@ async def unified_search(request: Request) -> JSONResponse: result_data["chunk_index"] = result.chunk_index result_data["total_chunks"] = result.total_chunks + # Add chunk offsets for modal navigation + if result.chunk_start_offset is not None: + result_data["chunk_start_offset"] = result.chunk_start_offset + if result.chunk_end_offset is not None: + result_data["chunk_end_offset"] = result.chunk_end_offset + formatted_results.append(result_data) response_data: dict[str, Any] = { diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py index a86673e..8f939be 100644 --- a/nextcloud_mcp_server/auth/browser_oauth_routes.py +++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py @@ -376,16 +376,35 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo user_id = f"user-{secrets.token_hex(8)}" username = "unknown" + # Calculate refresh token expiration from token response + refresh_expires_in = token_data.get("refresh_expires_in") + refresh_expires_at = None + if refresh_expires_in: + import time + + refresh_expires_at = int(time.time()) + refresh_expires_in + logger.info( + f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})" + ) + + # Extract granted scopes + granted_scopes = ( + token_data.get("scope", "").split() if token_data.get("scope") else None + ) + # Store refresh token (for background jobs ONLY) if refresh_token: logger.info(f"Storing refresh token for user_id: {user_id}") logger.info(f" State parameter (provisioning_client_id): {state[:16]}...") + logger.info(f" Granted scopes: {granted_scopes}") + logger.info(f" Expires at: {refresh_expires_at}") await storage.store_refresh_token( user_id=user_id, refresh_token=refresh_token, - expires_at=None, + expires_at=refresh_expires_at, flow_type="browser", # Browser-based login flow provisioning_client_id=state, # Store state for unified session lookup + scopes=granted_scopes, ) logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}") logger.info( diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index 35ae823..5499aca 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -517,12 +517,23 @@ async def oauth_callback_nextcloud(request: Request): token_data.get("scope", "").split() if token_data.get("scope") else None ) + # Calculate refresh token expiration from token response + refresh_expires_in = token_data.get("refresh_expires_in") + refresh_expires_at = None + if refresh_expires_in: + import time + + refresh_expires_at = int(time.time()) + refresh_expires_in + logger.info(f" refresh_expires_in: {refresh_expires_in}s") + logger.info(f" refresh_expires_at: {refresh_expires_at}") + logger.info("Storing refresh token:") logger.info(f" user_id: {user_id}") logger.info(" flow_type: flow2") logger.info(" token_audience: nextcloud") logger.info(f" provisioning_client_id: {state[:16]}...") logger.info(f" scopes: {granted_scopes}") + logger.info(f" expires_at: {refresh_expires_at}") await storage.store_refresh_token( user_id=user_id, @@ -531,7 +542,7 @@ async def oauth_callback_nextcloud(request: Request): token_audience="nextcloud", provisioning_client_id=state, # Store which client initiated provisioning scopes=granted_scopes, - expires_at=None, # Refresh tokens typically don't expire + expires_at=refresh_expires_at, ) logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}") logger.info("=" * 60) diff --git a/nextcloud_mcp_server/auth/storage.py b/nextcloud_mcp_server/auth/storage.py index 9f4c1ff..b8bc898 100644 --- a/nextcloud_mcp_server/auth/storage.py +++ b/nextcloud_mcp_server/auth/storage.py @@ -117,7 +117,14 @@ class RefreshTokenStorage: return cls(db_path=db_path, encryption_key=encryption_key) async def initialize(self) -> None: - """Initialize database schema""" + """ + Initialize database schema using Alembic migrations. + + This method handles three scenarios: + 1. New database: Run migrations from scratch + 2. Pre-Alembic database: Stamp with initial revision (no changes) + 3. Alembic-managed database: Upgrade to latest version + """ if self._initialized: return @@ -125,137 +132,59 @@ class RefreshTokenStorage: db_dir = Path(self.db_path).parent db_dir.mkdir(parents=True, exist_ok=True) - # Set restrictive permissions on database file + # Set restrictive permissions on database file if it exists if Path(self.db_path).exists(): os.chmod(self.db_path, 0o600) + # Check database state and run appropriate migration strategy async with aiosqlite.connect(self.db_path) as db: - await db.execute( - """ - CREATE TABLE IF NOT EXISTS refresh_tokens ( - user_id TEXT PRIMARY KEY, - encrypted_token BLOB NOT NULL, - expires_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - -- ADR-004 Progressive Consent fields - flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2' - token_audience TEXT DEFAULT 'nextcloud', -- 'mcp-server' or 'nextcloud' - provisioned_at INTEGER, -- When Flow 2 was completed - provisioning_client_id TEXT, -- Which MCP client initiated Flow 1 - scopes TEXT, -- JSON array of granted scopes - -- Browser session profile cache - user_profile TEXT, -- JSON cache of IdP user profile (for browser UI only) - profile_cached_at INTEGER -- When profile was last cached + # Check if database is managed by Alembic + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'" + ) + has_alembic = await cursor.fetchone() is not None + + if not has_alembic: + # Check if this is a pre-Alembic database with existing schema + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'" ) - """ - ) + has_schema = await cursor.fetchone() is not None - await db.execute( - """ - CREATE TABLE IF NOT EXISTS audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp INTEGER NOT NULL, - event TEXT NOT NULL, - user_id TEXT NOT NULL, - resource_type TEXT, - resource_id TEXT, - auth_method TEXT, - hostname TEXT + if has_schema: + logger.info( + f"Detected pre-Alembic database at {self.db_path}, " + "stamping with initial revision" + ) + else: + logger.info( + f"Initializing new database at {self.db_path} with migrations" + ) + + # Run migrations in a worker thread using anyio.to_thread + # This allows Alembic to run its own async operations in a separate context + from anyio import to_thread + + from nextcloud_mcp_server.migrations import stamp_database, upgrade_database + + if not has_alembic: + if has_schema: + # Stamp existing database without running migrations + await to_thread.run_sync(stamp_database, self.db_path, "001") + logger.info( + "Pre-Alembic database stamped successfully. " + "Future schema changes will use migrations." ) - """ - ) + else: + # New database - run migrations + await to_thread.run_sync(upgrade_database, self.db_path, "head") + logger.info("Database initialized with migrations") + else: + # Alembic-managed database - upgrade to latest + await to_thread.run_sync(upgrade_database, self.db_path, "head") + logger.info("Database upgraded to latest version") - # Create index on audit logs for efficient queries - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp " - "ON audit_logs(user_id, timestamp)" - ) - - # OAuth client credentials storage - await db.execute( - """ - CREATE TABLE IF NOT EXISTS oauth_clients ( - id INTEGER PRIMARY KEY, - client_id TEXT UNIQUE NOT NULL, - encrypted_client_secret BLOB NOT NULL, - client_id_issued_at INTEGER NOT NULL, - client_secret_expires_at INTEGER NOT NULL, - redirect_uris TEXT NOT NULL, - encrypted_registration_access_token BLOB, - registration_client_uri TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - """ - ) - - # OAuth flow sessions (ADR-004 Progressive Consent) - await db.execute( - """ - CREATE TABLE IF NOT EXISTS oauth_sessions ( - session_id TEXT PRIMARY KEY, - client_id TEXT, - client_redirect_uri TEXT NOT NULL, - state TEXT, - code_challenge TEXT, - code_challenge_method TEXT, - mcp_authorization_code TEXT UNIQUE, - idp_access_token TEXT, - idp_refresh_token TEXT, - user_id TEXT, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - -- ADR-004 Progressive Consent fields - flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2' - requested_scopes TEXT, -- JSON array of requested scopes - granted_scopes TEXT, -- JSON array of granted scopes - is_provisioning BOOLEAN DEFAULT FALSE -- True if this is a Flow 2 provisioning session - ) - """ - ) - - # Create index for MCP authorization code lookups - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code " - "ON oauth_sessions(mcp_authorization_code)" - ) - - # Schema version tracking - await db.execute( - """ - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at REAL NOT NULL - ) - """ - ) - - # Registered webhooks tracking (both BasicAuth and OAuth modes) - await db.execute( - """ - CREATE TABLE IF NOT EXISTS registered_webhooks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - webhook_id INTEGER NOT NULL UNIQUE, - preset_id TEXT NOT NULL, - created_at REAL NOT NULL - ) - """ - ) - - # Create indexes for efficient webhook queries - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_webhooks_preset " - "ON registered_webhooks(preset_id)" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_webhooks_created " - "ON registered_webhooks(created_at)" - ) - - await db.commit() - - # Set restrictive permissions after creation + # Set restrictive permissions after initialization os.chmod(self.db_path, 0o600) self._initialized = True diff --git a/nextcloud_mcp_server/cli.py b/nextcloud_mcp_server/cli.py index a7b9d06..94ce3db 100644 --- a/nextcloud_mcp_server/cli.py +++ b/nextcloud_mcp_server/cli.py @@ -253,5 +253,195 @@ def run( ) +@click.group() +def db(): + """Database migration management commands.""" + pass + + +@db.command() +@click.option( + "--database-path", + "-d", + envvar="TOKEN_STORAGE_DB", + default="/app/data/tokens.db", + show_default=True, + help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)", +) +@click.option( + "--revision", + "-r", + default="head", + show_default=True, + help="Target revision (default: head for latest)", +) +def upgrade(database_path: str, revision: str): + """Upgrade database to a specific revision. + + \b + Examples: + # Upgrade to latest version + $ nextcloud-mcp-server db upgrade + + # Upgrade to specific revision + $ nextcloud-mcp-server db upgrade --revision 001 + + # Use custom database path + $ nextcloud-mcp-server db upgrade -d /path/to/tokens.db + """ + from nextcloud_mcp_server.migrations import upgrade_database + + try: + click.echo(f"Upgrading database to revision: {revision}") + upgrade_database(database_path, revision) + click.echo(click.style("✓ Database upgraded successfully", fg="green")) + except Exception as e: + click.echo(click.style(f"✗ Upgrade failed: {e}", fg="red"), err=True) + raise click.ClickException(str(e)) + + +@db.command() +@click.option( + "--database-path", + "-d", + envvar="TOKEN_STORAGE_DB", + default="/app/data/tokens.db", + show_default=True, + help="Path to token storage database", +) +@click.option( + "--revision", + "-r", + default="-1", + show_default=True, + help="Target revision (default: -1 for previous version)", +) +@click.confirmation_option( + prompt="Are you sure you want to downgrade the database? This may result in data loss." +) +def downgrade(database_path: str, revision: str): + """Downgrade database to a specific revision. + + WARNING: This may result in data loss! Use with caution. + + \b + Examples: + # Downgrade by one version + $ nextcloud-mcp-server db downgrade + + # Downgrade to specific revision + $ nextcloud-mcp-server db downgrade --revision 001 + + # Downgrade to base (empty database) + $ nextcloud-mcp-server db downgrade --revision base + """ + from nextcloud_mcp_server.migrations import downgrade_database + + try: + click.echo(f"Downgrading database to revision: {revision}") + downgrade_database(database_path, revision) + click.echo(click.style("✓ Database downgraded successfully", fg="green")) + except Exception as e: + click.echo(click.style(f"✗ Downgrade failed: {e}", fg="red"), err=True) + raise click.ClickException(str(e)) + + +@db.command() +@click.option( + "--database-path", + "-d", + envvar="TOKEN_STORAGE_DB", + default="/app/data/tokens.db", + show_default=True, + help="Path to token storage database", +) +def current(database_path: str): + """Show current database revision. + + \b + Example: + $ nextcloud-mcp-server db current + """ + from nextcloud_mcp_server.migrations import get_current_revision + + try: + revision = get_current_revision(database_path) + if revision: + click.echo(f"Current revision: {click.style(revision, fg='cyan')}") + else: + click.echo( + click.style( + "Database is not versioned (no alembic_version table)", fg="yellow" + ) + ) + except Exception as e: + click.echo( + click.style(f"✗ Failed to get current revision: {e}", fg="red"), err=True + ) + raise click.ClickException(str(e)) + + +@db.command() +@click.option( + "--database-path", + "-d", + envvar="TOKEN_STORAGE_DB", + default="/app/data/tokens.db", + show_default=True, + help="Path to token storage database", +) +def history(database_path: str): + """Show migration history. + + \b + Example: + $ nextcloud-mcp-server db history + """ + from nextcloud_mcp_server.migrations import show_migration_history + + try: + click.echo("Migration history:") + show_migration_history(database_path) + except Exception as e: + click.echo(click.style(f"✗ Failed to show history: {e}", fg="red"), err=True) + raise click.ClickException(str(e)) + + +@db.command() +@click.argument("message") +def migrate(message: str): + """Create a new migration script (developers only). + + The MESSAGE argument describes the changes in this migration. + + \b + Examples: + $ nextcloud-mcp-server db migrate "add user preferences table" + $ nextcloud-mcp-server db migrate "add index on refresh_tokens.user_id" + + Note: You must manually edit the generated migration file to add SQL statements. + """ + from nextcloud_mcp_server.migrations import create_migration + + try: + click.echo(f"Creating new migration: {message}") + create_migration(message) + click.echo(click.style("✓ Migration created successfully", fg="green")) + click.echo( + "Edit the migration file in alembic/versions/ to add upgrade/downgrade SQL." + ) + except Exception as e: + click.echo( + click.style(f"✗ Failed to create migration: {e}", fg="red"), err=True + ) + raise click.ClickException(str(e)) + + +# Create CLI group with subcommands +cli = click.Group() +cli.add_command(run) +cli.add_command(db) + + if __name__ == "__main__": - run() + cli() diff --git a/nextcloud_mcp_server/migrations.py b/nextcloud_mcp_server/migrations.py new file mode 100644 index 0000000..bbff578 --- /dev/null +++ b/nextcloud_mcp_server/migrations.py @@ -0,0 +1,186 @@ +"""Database migration utilities for nextcloud-mcp-server. + +This module provides helper functions for managing Alembic database migrations +programmatically. It enables automatic migration on application startup and +provides CLI integration. +""" + +import logging +from pathlib import Path + +from alembic.config import Config + +from alembic import command + +logger = logging.getLogger(__name__) + + +def get_alembic_config(database_path: str | Path | None = None) -> Config: + """ + Get Alembic configuration for programmatic use. + + Args: + database_path: Path to SQLite database file. If None, uses default + from alembic.ini (/app/data/tokens.db) + + Returns: + Alembic Config object configured for the specified database + """ + # Get path to alembic.ini (in project root) + project_root = Path(__file__).parent.parent + alembic_ini_path = project_root / "alembic.ini" + + if not alembic_ini_path.exists(): + raise FileNotFoundError( + f"alembic.ini not found at {alembic_ini_path}. " + "Ensure Alembic is properly initialized." + ) + + # Create Alembic config + config = Config(str(alembic_ini_path)) + + # Override database URL if provided + if database_path: + db_path = Path(database_path).resolve() + # Use sqlite+aiosqlite:// for async support + url = f"sqlite+aiosqlite:///{db_path}" + config.set_main_option("sqlalchemy.url", url) + logger.debug(f"Alembic configured with database: {db_path}") + + return config + + +def upgrade_database( + database_path: str | Path | None = None, revision: str = "head" +) -> None: + """ + Upgrade database to a specific revision. + + Args: + database_path: Path to SQLite database file + revision: Target revision (default: "head" for latest) + """ + config = get_alembic_config(database_path) + logger.info(f"Upgrading database to revision: {revision}") + command.upgrade(config, revision) + logger.info("Database upgrade completed successfully") + + +def downgrade_database( + database_path: str | Path | None = None, revision: str = "-1" +) -> None: + """ + Downgrade database to a specific revision. + + Args: + database_path: Path to SQLite database file + revision: Target revision (default: "-1" for previous version) + """ + config = get_alembic_config(database_path) + logger.warning(f"Downgrading database to revision: {revision}") + command.downgrade(config, revision) + logger.info("Database downgrade completed successfully") + + +def get_current_revision(database_path: str | Path | None = None) -> str | None: + """ + Get the current database revision by directly querying the alembic_version table. + + Args: + database_path: Path to SQLite database file + + Returns: + Current revision ID or None if not versioned + """ + import sqlite3 + + if database_path is None: + database_path = "/app/data/tokens.db" + + db_path = Path(database_path).resolve() + + if not db_path.exists(): + logger.debug(f"Database does not exist: {db_path}") + return None + + try: + # Query alembic_version table directly + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + # Check if alembic_version table exists + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'" + ) + has_table = cursor.fetchone() is not None + + if not has_table: + conn.close() + return None + + # Get current version + cursor.execute("SELECT version_num FROM alembic_version") + row = cursor.fetchone() + conn.close() + + return row[0] if row else None + + except Exception as e: + logger.error(f"Failed to get current revision: {e}") + return None + + +def stamp_database( + database_path: str | Path | None = None, revision: str = "head" +) -> None: + """ + Stamp database with a specific revision without running migrations. + + This is useful for marking existing databases that were created before + Alembic was introduced. It tells Alembic "this database is at revision X" + without actually running the migration. + + Args: + database_path: Path to SQLite database file + revision: Revision to stamp (default: "head" for latest) + """ + config = get_alembic_config(database_path) + logger.info(f"Stamping database with revision: {revision}") + command.stamp(config, revision) + logger.info("Database stamped successfully") + + +def show_migration_history(database_path: str | Path | None = None) -> None: + """ + Display migration history. + + Args: + database_path: Path to SQLite database file + """ + config = get_alembic_config(database_path) + command.history(config, verbose=True) + + +def create_migration(message: str, autogenerate: bool = False) -> None: + """ + Create a new migration script. + + Args: + message: Description of the migration + autogenerate: Whether to attempt auto-generation (requires SQLAlchemy models) + + Note: + Since we don't use SQLAlchemy models, autogenerate will be disabled + and migrations must be written manually. + """ + config = get_alembic_config() + logger.info(f"Creating new migration: {message}") + + if autogenerate: + logger.warning( + "Auto-generation is not supported (no SQLAlchemy models). " + "Migration will be created with empty upgrade/downgrade functions." + ) + + command.revision(config, message=message, autogenerate=False) + logger.info("Migration created successfully. Edit the file to add SQL statements.") diff --git a/pyproject.toml b/pyproject.toml index 8823ce8..0e403f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "caldav", "pyjwt[crypto]>=2.8.0", "aiosqlite>=0.20.0", # Async SQLite for refresh token storage + "alembic>=1.14.0", # Database migrations "authlib>=1.6.5", "qdrant-client>=1.7.0", "fastembed>=0.7.3", # BM25 sparse vector embeddings for hybrid search @@ -128,7 +129,7 @@ dev = [ ] [project.scripts] -nextcloud-mcp-server = "nextcloud_mcp_server.cli:run" +nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli" smithery-main = "nextcloud_mcp_server.smithery_main:main" [[tool.uv.index]] diff --git a/third_party/astrolabe/lib/Search/SemanticSearchProvider.php b/third_party/astrolabe/lib/Search/SemanticSearchProvider.php index e4f5d45..8da0cb4 100644 --- a/third_party/astrolabe/lib/Search/SemanticSearchProvider.php +++ b/third_party/astrolabe/lib/Search/SemanticSearchProvider.php @@ -223,39 +223,51 @@ class SemanticSearchProvider implements IProvider { } /** - * Build URL to navigate to the original document. + * Build URL to navigate to Astrolabe with chunk viewer. * - * URL formats match App.vue's getDocumentUrl() implementation for consistency. + * Links to Astrolabe app with query parameters that trigger the chunk modal, + * allowing users to preview the chunk before navigating to the full document. */ private function buildResourceUrl(array $result): string { + // Build base URL to Astrolabe app + $baseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index'); + + // Extract chunk parameters $docType = $result['doc_type'] ?? 'unknown'; $id = $result['id'] ?? null; - $path = $result['path'] ?? null; + $chunkStart = $result['chunk_start_offset'] ?? null; + $chunkEnd = $result['chunk_end_offset'] ?? null; - return match ($docType) { - 'note' => $id - ? $this->urlGenerator->linkToRoute('notes.page.index') . '/#/note/' . $id - : $this->urlGenerator->linkToRoute('notes.page.index'), + // If we have chunk information, build URL with parameters + if ($id !== null && $chunkStart !== null && $chunkEnd !== null) { + $params = [ + 'doc_type' => $docType, + 'doc_id' => $id, + 'chunk_start' => $chunkStart, + 'chunk_end' => $chunkEnd, + ]; - 'file' => $id - ? $this->urlGenerator->linkToRouteAbsolute('files.view.index') . 'files/' . $id . '?dir=/&editing=false&openfile=true' - : $this->urlGenerator->linkToRouteAbsolute('files.view.index'), + // Add optional metadata + if (isset($result['title'])) { + $params['title'] = $result['title']; + } + if (isset($result['path'])) { + $params['path'] = $result['path']; + } + if (isset($result['page_number'])) { + $params['page_number'] = $result['page_number']; + } + if (isset($result['board_id'])) { + $params['board_id'] = $result['board_id']; + } - 'deck_card' => isset($result['board_id']) && $id - ? $this->urlGenerator->linkToRoute('deck.page.index') - . "board/{$result['board_id']}/card/{$id}" - : $this->urlGenerator->linkToRoute('deck.page.index'), + // Encode parameters for URL + $queryString = http_build_query($params); + return $baseUrl . '?' . $queryString; + } - 'calendar', 'calendar_event' => $this->urlGenerator->linkToRoute('calendar.view.index'), - - 'news_item' => $id - ? $this->urlGenerator->linkToRoute('news.page.index') . 'item/' . $id - : $this->urlGenerator->linkToRoute('news.page.index'), - - 'contact' => $this->urlGenerator->linkToRoute('contacts.page.index'), - - default => $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index'), - }; + // Fallback to base URL if no chunk information + return $baseUrl; } /** diff --git a/third_party/astrolabe/src/App.vue b/third_party/astrolabe/src/App.vue index 3ecf271..0958225 100644 --- a/third_party/astrolabe/src/App.vue +++ b/third_party/astrolabe/src/App.vue @@ -501,6 +501,10 @@ export default { return this.results.filter(r => (r.score || 0) >= threshold) }, }, + mounted() { + // Check for URL parameters to open chunk viewer + this.handleUrlParameters() + }, beforeDestroy() { // Clean up Plotly event handlers to prevent memory leaks const plotDiv = document.getElementById('viz-plot') @@ -509,6 +513,51 @@ export default { } }, methods: { + handleUrlParameters() { + // Parse URL parameters + const urlParams = new URLSearchParams(window.location.search) + const docType = urlParams.get('doc_type') + const docId = urlParams.get('doc_id') + const chunkStart = urlParams.get('chunk_start') + const chunkEnd = urlParams.get('chunk_end') + + // If we have chunk parameters, open the viewer + if (docType && docId && chunkStart !== null && chunkEnd !== null) { + // Construct a minimal result object + const result = { + doc_type: docType, + id: parseInt(docId, 10), + chunk_start_offset: parseInt(chunkStart, 10), + chunk_end_offset: parseInt(chunkEnd, 10), + title: urlParams.get('title') || this.t('astrolabe', 'Chunk Viewer'), + metadata: {}, + } + + // Add optional metadata + const path = urlParams.get('path') + if (path) { + result.metadata.path = path + } + const pageNumber = urlParams.get('page_number') + if (pageNumber) { + result.page_number = parseInt(pageNumber, 10) + } + const boardId = urlParams.get('board_id') + if (boardId) { + result.metadata.board_id = boardId + } + + // Open the chunk viewer + this.$nextTick(() => { + this.viewChunk(result) + }) + + // Clear URL parameters to avoid reopening on navigation + const newUrl = window.location.pathname + window.history.replaceState({}, '', newUrl) + } + }, + toggleDocType(docTypeId, checked) { if (checked && !this.selectedDocTypes.includes(docTypeId)) { this.selectedDocTypes.push(docTypeId) @@ -616,6 +665,12 @@ export default { case 'note': return generateUrl(`/apps/notes/#/note/${id}`) case 'file': + // For PDFs with page numbers, use the PDF viewer with page anchor + if (result.page_number && metadata.path) { + const pageParam = `#page=${result.page_number}` + return generateUrl(`/apps/files_pdfviewer/?file=${encodeURIComponent(metadata.path)}${pageParam}`) + } + // For other files, use the standard file viewer if (id) { return generateUrl(`/apps/files/files/${id}?dir=/&editing=false&openfile=true`) } diff --git a/uv.lock b/uv.lock index 5b4eca1..c5c2ef5 100644 --- a/uv.lock +++ b/uv.lock @@ -144,6 +144,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, ] +[[package]] +name = "alembic" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -1558,6 +1572,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1966,6 +1992,7 @@ version = "0.52.1" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, + { name = "alembic" }, { name = "anthropic" }, { name = "authlib" }, { name = "boto3" }, @@ -2016,6 +2043,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "alembic", specifier = ">=1.14.0" }, { name = "anthropic", specifier = ">=0.42.0" }, { name = "authlib", specifier = ">=1.6.5" }, { name = "boto3", specifier = ">=1.35.0" }, @@ -3617,6 +3645,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + [[package]] name = "sse-starlette" version = "3.0.3"