Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""add neuroglancer_links table

Revision ID: a1b2c3d4e5f6
Revises: 9812335c52b6
Create Date: 2025-12-19 12:00:00.000000

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = '9812335c52b6'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table('neuroglancer_links',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('short_key', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=True),
sa.Column('ng_url_base', sa.String(), nullable=False),
sa.Column('state_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_neuroglancer_links_username', 'neuroglancer_links', ['username'], unique=False)
op.create_index('ix_neuroglancer_links_short_key', 'neuroglancer_links', ['short_key'], unique=True)


def downgrade() -> None:
op.drop_index('ix_neuroglancer_links_short_key', table_name='neuroglancer_links')
op.drop_index('ix_neuroglancer_links_username', table_name='neuroglancer_links')
op.drop_table('neuroglancer_links')
173 changes: 172 additions & 1 deletion fileglancer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from fastapi.exceptions import RequestValidationError, StarletteHTTPException
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from urllib.parse import quote, unquote
from urllib.parse import quote, unquote, urlparse, parse_qs

from fileglancer import database as db
from fileglancer import auth
Expand Down Expand Up @@ -119,6 +119,64 @@ def _convert_ticket(db_ticket: db.TicketDB) -> Ticket:
)


def _convert_neuroglancer_link(db_link: db.NeuroglancerLinkDB, external_proxy_url: Optional[HttpUrl]) -> NeuroglancerLink:
"""Convert a database NeuroglancerLinkDB model to a Pydantic NeuroglancerLink model"""
if external_proxy_url:
short_url = f"{external_proxy_url}/ng/{db_link.short_key}"
else:
logger.warning(f"No external proxy URL was provided, short links will not be available.")
short_url = None
return NeuroglancerLink(
short_key=db_link.short_key,
username=db_link.username,
title=db_link.title,
ng_url_base=db_link.ng_url_base,
state_json=db_link.state_json,
created_at=db_link.created_at,
updated_at=db_link.updated_at,
short_url=short_url
)


def _parse_neuroglancer_url(ng_url: str) -> Tuple[str, str]:
"""
Parse a Neuroglancer URL and extract the base URL and state JSON.

Handles formats:
- https://neuroglancer-demo.appspot.com/#!{JSON}
- https://neuroglancer-demo.appspot.com/#!%7B...%7D (URL-encoded)

Returns:
Tuple of (ng_url_base, state_json)

Raises:
ValueError: If the URL cannot be parsed
"""
if '#!' not in ng_url:
raise ValueError("Invalid Neuroglancer URL: missing '#!' fragment")

# Split URL at the #! marker
base_url, fragment = ng_url.split('#!', 1)

# Ensure base URL ends with /
if not base_url.endswith('/'):
base_url = base_url + '/'

# Try to decode the fragment (it might be URL-encoded)
try:
decoded_fragment = unquote(fragment)
except Exception:
decoded_fragment = fragment

# Validate that it's valid JSON
try:
json.loads(decoded_fragment)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON state in Neuroglancer URL: {e}")

return base_url, decoded_fragment


def _validate_filename(name: str) -> None:
"""
Validate that a filename/dirname is safe and only refers to a single item in the current directory.
Expand Down Expand Up @@ -713,6 +771,119 @@ async def delete_proxied_path(sharing_key: str = Path(..., description="The shar
return {"message": f"Proxied path {sharing_key} deleted for user {username}"}


# Neuroglancer short link endpoints
@app.post("/api/ng-link", response_model=NeuroglancerLink,
description="Create a new Neuroglancer short link")
async def create_ng_link(body: NeuroglancerLinkCreate,
username: str = Depends(get_current_user)):
"""Create a new shortened Neuroglancer link"""
try:
# Determine the URL base and state JSON
if body.ng_url:
# Parse from full Neuroglancer URL
ng_url_base, state_json = _parse_neuroglancer_url(body.ng_url)
elif body.state_json:
# Use provided state JSON directly
# Validate it's valid JSON
try:
json.loads(body.state_json)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON state: {e}")
ng_url_base = body.ng_url_base or "https://neuroglancer-demo.appspot.com/"
state_json = body.state_json
else:
raise HTTPException(status_code=400, detail="Either ng_url or state_json must be provided")

with db.get_db_session(settings.db_url) as session:
ng_link = db.create_neuroglancer_link(
session=session,
username=username,
title=body.title,
ng_url_base=ng_url_base,
state_json=state_json
)
return _convert_neuroglancer_link(ng_link, settings.external_proxy_url)

except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))


@app.get("/api/ng-link", response_model=NeuroglancerLinkResponse,
description="Get all Neuroglancer short links for the current user")
async def get_ng_links(username: str = Depends(get_current_user)):
"""Get all shortened Neuroglancer links for the authenticated user"""
with db.get_db_session(settings.db_url) as session:
db_links = db.get_neuroglancer_links(session, username)
links = [_convert_neuroglancer_link(link, settings.external_proxy_url) for link in db_links]
return NeuroglancerLinkResponse(links=links)


@app.get("/api/ng-link/{short_key}", response_model=NeuroglancerLink,
description="Get a Neuroglancer short link by its short key (public, no auth required)")
async def get_ng_link(short_key: str = Path(..., description="The short key of the link")):
"""Get a shortened Neuroglancer link by its key. This endpoint is public."""
with db.get_db_session(settings.db_url) as session:
ng_link = db.get_neuroglancer_link_by_key(session, short_key)
if not ng_link:
raise HTTPException(status_code=404, detail="Neuroglancer link not found")
return _convert_neuroglancer_link(ng_link, settings.external_proxy_url)


@app.put("/api/ng-link/{short_key}", response_model=NeuroglancerLink,
description="Update a Neuroglancer short link")
async def update_ng_link(short_key: str = Path(..., description="The short key of the link"),
body: NeuroglancerLinkUpdate = Body(...),
username: str = Depends(get_current_user)):
"""Update a shortened Neuroglancer link. Only the owner can update."""
with db.get_db_session(settings.db_url) as session:
# Validate state_json if provided
if body.state_json:
try:
json.loads(body.state_json)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON state: {e}")

ng_link = db.update_neuroglancer_link(
session=session,
username=username,
short_key=short_key,
title=body.title,
state_json=body.state_json
)
if not ng_link:
raise HTTPException(status_code=404, detail="Neuroglancer link not found or not owned by user")
return _convert_neuroglancer_link(ng_link, settings.external_proxy_url)


@app.delete("/api/ng-link/{short_key}",
description="Delete a Neuroglancer short link")
async def delete_ng_link(short_key: str = Path(..., description="The short key of the link"),
username: str = Depends(get_current_user)):
"""Delete a shortened Neuroglancer link. Only the owner can delete."""
with db.get_db_session(settings.db_url) as session:
deleted = db.delete_neuroglancer_link(session, username, short_key)
if not deleted:
raise HTTPException(status_code=404, detail="Neuroglancer link not found or not owned by user")
return {"deleted": True}


@app.get("/ng/{short_key}",
description="Redirect to full Neuroglancer URL (for browser access)")
async def redirect_ng_link(short_key: str = Path(..., description="The short key of the link")):
"""Redirect to the full Neuroglancer URL with state embedded"""
with db.get_db_session(settings.db_url) as session:
ng_link = db.get_neuroglancer_link_by_key(session, short_key)
if not ng_link:
raise HTTPException(status_code=404, detail="Neuroglancer link not found")

# Construct full Neuroglancer URL with state in fragment
# URL-encode the JSON state for the fragment
encoded_state = quote(ng_link.state_json, safe='')
full_url = f"{ng_link.ng_url_base}#!{encoded_state}"

return RedirectResponse(url=full_url, status_code=302)


@app.get("/files/{sharing_key}/{sharing_name}")
@app.get("/files/{sharing_key}/{sharing_name}/{path:path}")
async def target_dispatcher(request: Request,
Expand Down
72 changes: 71 additions & 1 deletion fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
from functools import lru_cache

from sqlalchemy import create_engine, Column, String, Integer, DateTime, JSON, UniqueConstraint
from sqlalchemy import create_engine, Column, String, Integer, DateTime, JSON, UniqueConstraint, Text, Index
from sqlalchemy.orm import sessionmaker, declarative_base, Session
from sqlalchemy.engine.url import make_url
from sqlalchemy.pool import StaticPool
Expand Down Expand Up @@ -137,6 +137,24 @@ class SessionDB(Base):
last_accessed_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))


class NeuroglancerLinkDB(Base):
"""Database model for storing Neuroglancer short links"""
__tablename__ = 'neuroglancer_links'

id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String, nullable=False)
short_key = Column(String, nullable=False, unique=True)
title = Column(String, nullable=True)
ng_url_base = Column(String, nullable=False)
state_json = Column(Text, nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))

__table_args__ = (
Index('ix_neuroglancer_links_username', 'username'),
)


def run_alembic_upgrade(db_url):
"""Run Alembic migrations to upgrade database to latest version"""
global _migrations_run
Expand Down Expand Up @@ -658,3 +676,55 @@ def delete_expired_sessions(session: Session):
deleted = session.query(SessionDB).filter(SessionDB.expires_at < now).delete()
session.commit()
return deleted


# Neuroglancer Link functions
def get_neuroglancer_links(session: Session, username: str) -> List[NeuroglancerLinkDB]:
"""Get all Neuroglancer links for a user"""
return session.query(NeuroglancerLinkDB).filter_by(username=username).order_by(NeuroglancerLinkDB.created_at.desc()).all()


def get_neuroglancer_link_by_key(session: Session, short_key: str) -> Optional[NeuroglancerLinkDB]:
"""Get a Neuroglancer link by short key"""
return session.query(NeuroglancerLinkDB).filter_by(short_key=short_key).first()


def create_neuroglancer_link(session: Session, username: str, title: Optional[str], ng_url_base: str, state_json: str) -> NeuroglancerLinkDB:
"""Create a new Neuroglancer short link"""
short_key = secrets.token_urlsafe(SHARING_KEY_LENGTH)
now = datetime.now(UTC)
ng_link = NeuroglancerLinkDB(
username=username,
short_key=short_key,
title=title,
ng_url_base=ng_url_base,
state_json=state_json,
created_at=now,
updated_at=now
)
session.add(ng_link)
session.commit()
return ng_link


def update_neuroglancer_link(session: Session, username: str, short_key: str, title: Optional[str] = None, state_json: Optional[str] = None) -> Optional[NeuroglancerLinkDB]:
"""Update a Neuroglancer link. Returns None if not found or not owned by user."""
ng_link = get_neuroglancer_link_by_key(session, short_key)
if not ng_link or ng_link.username != username:
return None

if title is not None:
ng_link.title = title
if state_json is not None:
ng_link.state_json = state_json

ng_link.updated_at = datetime.now(UTC)
session.commit()
return ng_link


def delete_neuroglancer_link(session: Session, username: str, short_key: str) -> bool:
"""Delete a Neuroglancer link. Returns True if deleted, False if not found or not owned by user."""
deleted = session.query(NeuroglancerLinkDB).filter_by(username=username, short_key=short_key).delete()
session.commit()
return deleted > 0
68 changes: 68 additions & 0 deletions fileglancer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,74 @@ class ProxiedPathResponse(BaseModel):
)


class NeuroglancerLink(BaseModel):
"""A shortened Neuroglancer link"""
short_key: str = Field(
description="The unique short key for this link"
)
username: str = Field(
description="The username of the user who created this link"
)
title: Optional[str] = Field(
description="An optional title for this link",
default=None
)
ng_url_base: str = Field(
description="The base URL for Neuroglancer (e.g. https://neuroglancer-demo.appspot.com/)"
)
state_json: str = Field(
description="The Neuroglancer state as a JSON string"
)
created_at: datetime = Field(
description="When this link was created"
)
updated_at: datetime = Field(
description="When this link was last updated"
)
short_url: Optional[str] = Field(
description="The short URL for accessing this link",
default=None
)


class NeuroglancerLinkCreate(BaseModel):
"""Request body for creating a Neuroglancer short link"""
ng_url: Optional[str] = Field(
description="Full Neuroglancer URL with state in the fragment (e.g. https://neuroglancer-demo.appspot.com/#!{...})",
default=None
)
state_json: Optional[str] = Field(
description="Direct JSON state string (alternative to ng_url)",
default=None
)
ng_url_base: Optional[str] = Field(
description="The base URL for Neuroglancer when providing state_json directly",
default="https://neuroglancer-demo.appspot.com/"
)
title: Optional[str] = Field(
description="An optional title for this link",
default=None
)


class NeuroglancerLinkUpdate(BaseModel):
"""Request body for updating a Neuroglancer short link"""
title: Optional[str] = Field(
description="An optional title for this link",
default=None
)
state_json: Optional[str] = Field(
description="Updated JSON state string",
default=None
)


class NeuroglancerLinkResponse(BaseModel):
links: List[NeuroglancerLink] = Field(
description="A list of Neuroglancer links"
)


class ExternalBucket(BaseModel):
"""An external bucket for S3-compatible storage"""
id: int = Field(
Expand Down
Loading