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,42 @@
"""add neuroglancer_states table

Revision ID: 2d1f0e6b8c91
Revises: 9812335c52b6
Create Date: 2025-10-22 00:00:00.000000

"""
from alembic import op
import sqlalchemy as sa


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


def upgrade() -> None:
op.create_table(
'neuroglancer_states',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('short_key', sa.String(), nullable=False),
sa.Column('short_name', sa.String(), nullable=True),
sa.Column('username', sa.String(), nullable=False),
sa.Column('url_base', sa.String(), nullable=False),
sa.Column('state', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('short_key', name='uq_neuroglancer_states_short_key')
)
op.create_index(
'ix_neuroglancer_states_short_key',
'neuroglancer_states',
['short_key'],
unique=True
)


def downgrade() -> None:
op.drop_index('ix_neuroglancer_states_short_key', table_name='neuroglancer_states')
op.drop_table('neuroglancer_states')
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""add short_name to neuroglancer_states

Revision ID: 3c5b7a9f2c11
Revises: 2d1f0e6b8c91
Create Date: 2025-10-22 00:00:00.000000

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '3c5b7a9f2c11'
down_revision = '2d1f0e6b8c91'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('neuroglancer_states', sa.Column('short_name', sa.String(), nullable=True))


def downgrade() -> None:
op.drop_column('neuroglancer_states', 'short_name')
123 changes: 123 additions & 0 deletions fileglancer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,46 @@ def _validate_filename(name: str) -> None:
raise HTTPException(status_code=400, detail="File or directory name cannot have leading or trailing whitespace")


def _parse_neuroglancer_url(url: str) -> Tuple[str, Dict]:
"""
Parse a Neuroglancer URL and return its base URL and decoded JSON state.
"""
if not url or "#!" not in url:
raise HTTPException(status_code=400, detail="Neuroglancer URL must include a '#!' state fragment")

url_base, encoded_state = url.split("#!", 1)
if not url_base.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Neuroglancer URL must start with http or https")

decoded_state = unquote(encoded_state)
if decoded_state.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Shortened Neuroglancer URLs are not supported; provide a full state URL")

try:
state = json.loads(decoded_state)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Neuroglancer state must be valid JSON")

if not isinstance(state, dict):
raise HTTPException(status_code=400, detail="Neuroglancer state must be a JSON object")

return url_base, state


def _normalize_short_key(short_key: Optional[str]) -> Optional[str]:
if short_key is None:
return None
normalized = short_key.strip()
return normalized or None


def _validate_short_key(short_key: str) -> None:
if len(short_key) < 4 or len(short_key) > 64:
raise HTTPException(status_code=400, detail="short_key must be between 4 and 64 characters")
if not all(ch.isalnum() or ch in ("-", "_") for ch in short_key):
raise HTTPException(status_code=400, detail="short_key may only contain letters, numbers, '-' or '_'")


def create_app(settings):

# Initialize OAuth client for OKTA
Expand Down Expand Up @@ -643,6 +683,58 @@ async def delete_preference(key: str, username: str = Depends(get_current_user))
return {"message": f"Preference {key} deleted for user {username}"}


@app.post("/api/neuroglancer/shorten", response_model=NeuroglancerShortenResponse,
description="Store a Neuroglancer state and return a shortened link")
async def shorten_neuroglancer_state(request: Request,
payload: NeuroglancerShortenRequest,
username: str = Depends(get_current_user)):
short_key = _normalize_short_key(payload.short_key)
if short_key:
_validate_short_key(short_key)
short_name = payload.short_name.strip() if payload.short_name else None

if payload.url and payload.state:
raise HTTPException(status_code=400, detail="Provide either url or state, not both")

if payload.url:
url_base, state = _parse_neuroglancer_url(payload.url.strip())
elif payload.state:
if not payload.url_base:
raise HTTPException(status_code=400, detail="url_base is required when providing state directly")
if not isinstance(payload.state, dict):
raise HTTPException(status_code=400, detail="state must be a JSON object")
url_base = payload.url_base.strip()
if not url_base.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="url_base must start with http or https")
state = payload.state
else:
raise HTTPException(status_code=400, detail="Either url or state must be provided")

with db.get_db_session(settings.db_url) as session:
try:
entry = db.create_neuroglancer_state(
session,
username,
url_base,
state,
short_key=short_key,
short_name=short_name
)
created_short_key = entry.short_key
created_short_name = entry.short_name
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))

state_url = str(request.url_for("get_neuroglancer_state", short_key=created_short_key))
neuroglancer_url = f"{url_base}#!{state_url}"
return NeuroglancerShortenResponse(
short_key=created_short_key,
short_name=created_short_name,
state_url=state_url,
neuroglancer_url=neuroglancer_url
)


@app.post("/api/proxied-path", response_model=ProxiedPath,
description="Create a new proxied path")
async def create_proxied_path(fsp_name: str = Query(..., description="The name of the file share path that this proxied path is associated with"),
Expand Down Expand Up @@ -713,6 +805,37 @@ async def delete_proxied_path(sharing_key: str = Path(..., description="The shar
return {"message": f"Proxied path {sharing_key} deleted for user {username}"}


@app.get("/ng/{short_key}", name="get_neuroglancer_state", include_in_schema=False)
async def get_neuroglancer_state(short_key: str = Path(..., description="Short key for a stored Neuroglancer state")):
with db.get_db_session(settings.db_url) as session:
entry = db.get_neuroglancer_state(session, short_key)
if not entry:
raise HTTPException(status_code=404, detail="Neuroglancer state not found")
return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"})


@app.get("/api/neuroglancer/short-links", response_model=NeuroglancerShortLinkResponse,
description="List stored Neuroglancer short links for the current user")
async def get_neuroglancer_short_links(request: Request,
username: str = Depends(get_current_user)):
links = []
with db.get_db_session(settings.db_url) as session:
entries = db.get_neuroglancer_states(session, username)
for entry in entries:
state_url = str(request.url_for("get_neuroglancer_state", short_key=entry.short_key))
neuroglancer_url = f"{entry.url_base}#!{state_url}"
links.append(NeuroglancerShortLink(
short_key=entry.short_key,
short_name=entry.short_name,
created_at=entry.created_at,
updated_at=entry.updated_at,
state_url=state_url,
neuroglancer_url=neuroglancer_url
))

return NeuroglancerShortLinkResponse(links=links)


@app.get("/files/{sharing_key}/{sharing_name}")
@app.get("/files/{sharing_key}/{sharing_name}/{path:path}")
async def target_dispatcher(request: Request,
Expand Down
75 changes: 75 additions & 0 deletions fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

# Constants
SHARING_KEY_LENGTH = 12
NEUROGLANCER_SHORT_KEY_LENGTH = 12

# Global flag to track if migrations have been run
_migrations_run = False
Expand Down Expand Up @@ -102,6 +103,20 @@ class ProxiedPathDB(Base):
)


class NeuroglancerStateDB(Base):
"""Database model for storing Neuroglancer states"""
__tablename__ = 'neuroglancer_states'

id = Column(Integer, primary_key=True, autoincrement=True)
short_key = Column(String, nullable=False, unique=True, index=True)
short_name = Column(String, nullable=True)
username = Column(String, nullable=False)
url_base = Column(String, nullable=False)
state = Column(JSON, 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))


class TicketDB(Base):
"""Database model for storing proxied paths"""
__tablename__ = 'tickets'
Expand Down Expand Up @@ -571,6 +586,66 @@ def delete_proxied_path(session: Session, username: str, sharing_key: str):
_invalidate_sharing_key_cache(sharing_key)


def _generate_unique_neuroglancer_key(session: Session) -> str:
"""Generate a unique short key for Neuroglancer states."""
for _ in range(10):
candidate = secrets.token_urlsafe(NEUROGLANCER_SHORT_KEY_LENGTH)
exists = session.query(NeuroglancerStateDB).filter_by(short_key=candidate).first()
if not exists:
return candidate
raise RuntimeError("Failed to generate a unique Neuroglancer short key")


def _validate_custom_neuroglancer_key(session: Session, short_key: str) -> None:
"""Ensure the custom short key is available."""
exists = session.query(NeuroglancerStateDB).filter_by(short_key=short_key).first()
if exists:
raise ValueError("Short key is already in use")


def create_neuroglancer_state(
session: Session,
username: str,
url_base: str,
state: Dict,
short_key: Optional[str] = None,
short_name: Optional[str] = None
) -> NeuroglancerStateDB:
"""Create a new Neuroglancer state entry and return it."""
if short_key:
_validate_custom_neuroglancer_key(session, short_key)
else:
short_key = _generate_unique_neuroglancer_key(session)
now = datetime.now(UTC)
entry = NeuroglancerStateDB(
short_key=short_key,
short_name=short_name,
username=username,
url_base=url_base,
state=state,
created_at=now,
updated_at=now
)
session.add(entry)
session.commit()
return entry


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


def get_neuroglancer_states(session: Session, username: str) -> List[NeuroglancerStateDB]:
"""Get all Neuroglancer states for a user, newest first."""
return (
session.query(NeuroglancerStateDB)
.filter_by(username=username)
.order_by(NeuroglancerStateDB.created_at.desc())
.all()
)


def get_tickets(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[TicketDB]:
"""Get tickets for a user, optionally filtered by fsp_name and path"""
logger.info(f"Getting tickets for {username} with fsp_name={fsp_name} and path={path}")
Expand Down
9 changes: 7 additions & 2 deletions fileglancer/filestore.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,13 @@ def _get_file_info_from_path(self, full_path: str, current_user: str = None) ->
Get the FileInfo for a file or directory at the given path.
"""
stat_result = os.stat(full_path)
# Regenerate the relative path to ensure it is not empty (None and empty string are converted to '.' here)
rel_path = os.path.relpath(full_path, self.root_path)
# Use real paths to avoid /var vs /private/var mismatches on macOS.
root_real = os.path.realpath(self.root_path)
full_real = os.path.realpath(full_path)
if full_real == root_real:
rel_path = '.'
else:
rel_path = os.path.relpath(full_real, root_real)
return FileInfo.from_stat(rel_path, full_path, stat_result, current_user)


Expand Down
70 changes: 70 additions & 0 deletions fileglancer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,73 @@ class NotificationResponse(BaseModel):
notifications: List[Notification] = Field(
description="A list of active notifications"
)


class NeuroglancerShortenRequest(BaseModel):
"""Request payload for creating a shortened Neuroglancer state"""
short_key: Optional[str] = Field(
description="Optional short key to use instead of a generated one",
default=None
)
short_name: Optional[str] = Field(
description="Optional human-friendly name for the short link",
default=None
)
url: Optional[str] = Field(
description="Neuroglancer URL containing the encoded JSON state after #!",
default=None
)
state: Optional[Dict] = Field(
description="Neuroglancer state as a JSON object",
default=None
)
url_base: Optional[str] = Field(
description="Base Neuroglancer URL, required when providing state directly",
default=None
)


class NeuroglancerShortenResponse(BaseModel):
"""Response payload for shortened Neuroglancer state"""
short_key: str = Field(
description="Short key for retrieving the stored state"
)
short_name: Optional[str] = Field(
description="Optional human-friendly name for the short link",
default=None
)
state_url: str = Field(
description="Absolute URL to the stored state JSON"
)
neuroglancer_url: str = Field(
description="Neuroglancer URL that references the stored state"
)


class NeuroglancerShortLink(BaseModel):
"""Stored Neuroglancer short link"""
short_key: str = Field(
description="Short key for retrieving the stored state"
)
short_name: Optional[str] = Field(
description="Optional human-friendly name for the short link",
default=None
)
created_at: datetime = Field(
description="When this short link was created"
)
updated_at: datetime = Field(
description="When this short link was last updated"
)
state_url: str = Field(
description="Absolute URL to the stored state JSON"
)
neuroglancer_url: str = Field(
description="Neuroglancer URL that references the stored state"
)


class NeuroglancerShortLinkResponse(BaseModel):
links: List[NeuroglancerShortLink] = Field(
description="A list of stored Neuroglancer short links"
)
Loading