From 34431ee785bd4db58f649839982582520de2ad51 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 23 Dec 2025 15:03:04 -0500 Subject: [PATCH] initial implementation using claude --- ...b2c3d4e5f6_add_neuroglancer_links_table.py | 38 ++ fileglancer/app.py | 173 ++++++++- fileglancer/database.py | 72 +++- fileglancer/model.py | 68 ++++ frontend/src/App.tsx | 9 + frontend/src/components/NeuroglancerLinks.tsx | 53 +++ .../ui/Dialogs/NeuroglancerLinkDialog.tsx | 360 ++++++++++++++++++ frontend/src/components/ui/Navbar/Navbar.tsx | 8 +- .../src/components/ui/Table/TableCard.tsx | 2 +- .../components/ui/Table/ngLinksColumns.tsx | 203 ++++++++++ frontend/src/queries/ngLinkQueries.ts | 298 +++++++++++++++ 11 files changed, 1280 insertions(+), 4 deletions(-) create mode 100644 fileglancer/alembic/versions/a1b2c3d4e5f6_add_neuroglancer_links_table.py create mode 100644 frontend/src/components/NeuroglancerLinks.tsx create mode 100644 frontend/src/components/ui/Dialogs/NeuroglancerLinkDialog.tsx create mode 100644 frontend/src/components/ui/Table/ngLinksColumns.tsx create mode 100644 frontend/src/queries/ngLinkQueries.ts diff --git a/fileglancer/alembic/versions/a1b2c3d4e5f6_add_neuroglancer_links_table.py b/fileglancer/alembic/versions/a1b2c3d4e5f6_add_neuroglancer_links_table.py new file mode 100644 index 00000000..8de4934f --- /dev/null +++ b/fileglancer/alembic/versions/a1b2c3d4e5f6_add_neuroglancer_links_table.py @@ -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') diff --git a/fileglancer/app.py b/fileglancer/app.py index 5b2beeec..77fb81ba 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -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 @@ -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. @@ -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, diff --git a/fileglancer/database.py b/fileglancer/database.py index 50f79b5b..620660c9 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -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 @@ -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 @@ -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 diff --git a/fileglancer/model.py b/fileglancer/model.py index 2156675a..6bfd3158 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -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( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e62dafa7..a5e70981 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; import Preferences from '@/components/Preferences'; import Links from '@/components/Links'; +import NeuroglancerLinks from '@/components/NeuroglancerLinks'; import Notifications from '@/components/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -98,6 +99,14 @@ const AppComponent = () => { } path="links" /> + + + + } + path="ng-links" + /> {tasksEnabled ? ( +
+ + Neuroglancer Links + + +
+ + Neuroglancer links allow you to save and share Neuroglancer viewer + states. Paste a Neuroglancer URL to create a short link that you can + share with collaborators. + + + {showCreateDialog ? ( + setShowCreateDialog(false)} + open={showCreateDialog} + /> + ) : null} + + ); +} diff --git a/frontend/src/components/ui/Dialogs/NeuroglancerLinkDialog.tsx b/frontend/src/components/ui/Dialogs/NeuroglancerLinkDialog.tsx new file mode 100644 index 00000000..eaf68e07 --- /dev/null +++ b/frontend/src/components/ui/Dialogs/NeuroglancerLinkDialog.tsx @@ -0,0 +1,360 @@ +/* eslint-disable react/destructuring-assignment */ +// Props are used for TypeScript type narrowing purposes and cannot be destructured at the beginning + +import { useState } from 'react'; +import { Button, Typography, Input, Textarea } from '@material-tailwind/react'; +import toast from 'react-hot-toast'; +import { HiClipboardCopy, HiExternalLink } from 'react-icons/hi'; + +import FgDialog from './FgDialog'; +import { + useCreateNgLinkMutation, + useUpdateNgLinkMutation, + useDeleteNgLinkMutation +} from '@/queries/ngLinkQueries'; +import type { NeuroglancerLink } from '@/queries/ngLinkQueries'; +import DeleteBtn from '@/components/ui/buttons/DeleteBtn'; + +interface CreateModeProps { + mode: 'create'; + open: boolean; + onClose: () => void; +} + +interface EditModeProps { + mode: 'edit'; + open: boolean; + onClose: () => void; + link: NeuroglancerLink; +} + +interface DeleteModeProps { + mode: 'delete'; + open: boolean; + onClose: () => void; + link: NeuroglancerLink; +} + +type NeuroglancerLinkDialogProps = + | CreateModeProps + | EditModeProps + | DeleteModeProps; + +export default function NeuroglancerLinkDialog( + props: NeuroglancerLinkDialogProps +) { + const { mode, open, onClose } = props; + const link = mode !== 'create' ? props.link : null; + + const [ngUrl, setNgUrl] = useState(''); + const [title, setTitle] = useState(link?.title || ''); + const [createdLink, setCreatedLink] = useState(null); + + const createMutation = useCreateNgLinkMutation(); + const updateMutation = useUpdateNgLinkMutation(); + const deleteMutation = useDeleteNgLinkMutation(); + + const handleCreate = async () => { + if (!ngUrl.trim()) { + toast.error('Please enter a Neuroglancer URL'); + return; + } + + try { + const result = await createMutation.mutateAsync({ + ng_url: ngUrl, + title: title.trim() || undefined + }); + setCreatedLink(result); + toast.success('Neuroglancer link created!'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to create link'; + toast.error(message); + } + }; + + const handleUpdate = async () => { + if (!link) { + return; + } + + try { + await updateMutation.mutateAsync({ + short_key: link.short_key, + title: title.trim() || null + }); + toast.success('Link updated!'); + onClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to update link'; + toast.error(message); + } + }; + + const handleDelete = async () => { + if (!link) { + return; + } + + try { + await deleteMutation.mutateAsync({ short_key: link.short_key }); + toast.success('Link deleted!'); + onClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to delete link'; + toast.error(message); + } + }; + + const handleCopyShortUrl = async () => { + const urlToCopy = createdLink?.short_url || link?.short_url; + if (urlToCopy) { + await navigator.clipboard.writeText(urlToCopy); + toast.success('Short URL copied to clipboard!'); + } + }; + + const handleOpenLink = () => { + const urlToOpen = createdLink?.short_url || link?.short_url; + if (urlToOpen) { + window.open(urlToOpen, '_blank'); + } + }; + + return ( + +
+ {mode === 'create' && !createdLink ? ( + <> + + Create Neuroglancer Link + + + Paste a Neuroglancer URL to create a short link. The URL should + contain the viewer state in the fragment (after #!). + +
+ + Neuroglancer URL * + +