Skip to content

feat: share an album over the local network - #1469

Merged
rohan-pandeyy merged 13 commits into
AOSSIE-Org:mainfrom
rohan-pandeyy:feat/lan-album-sharing
Aug 6, 2026
Merged

rohan-pandeyy merged 13 commits into
AOSSIE-Org:mainfrom
rohan-pandeyy:feat/lan-album-sharing

Conversation

@rohan-pandeyy

@rohan-pandeyy rohan-pandeyy commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #1468

Adds the ability to serve one album over the local network, so that a recipient on the same Wi-Fi can browse it in a browser while PictoPy is running. Nothing is uploaded, and the recipient needs no account and no app.

Approach

The album is served by a second FastAPI application bound to 0.0.0.0, started on demand when the first share is created and stopped when the last one is revoked. The main backend stays on localhost because it exposes shutdown, delete and metadata routes that must not be reachable from other devices, so binding it more widely was never an option.

The share server runs as an asyncio task on the backend's existing event loop rather than as a separate process, which means no packaging or Tauri changes are needed. Its socket is bound before uvicorn receives it, because uvicorn raises SystemExit on a bind failure and would otherwise terminate the whole backend when a port is busy.

Active shares are held in memory rather than in the database. Shares are meant to end when PictoPy closes, and keeping them in memory makes that structural instead of something a cleanup path has to enforce. There is no migration, and no token is ever written to disk.

Security

Four invariants, each covered by a test. An image is resolved to a file only after it has been confirmed to belong to the shared album, so a token cannot be used to read other images. Filesystem paths are never sent to the client, which works entirely in IDs. A revoked, expired or unknown token returns the same 404, so the response cannot be used to probe for valid tokens. The share application carries no CORS middleware and no interactive documentation, and exposes nothing outside /s/{token}.

An album's local lock is deliberately not consulted. Locking protects an album inside PictoPy, while a share carries its own authorization.

Receiver page

The recipient gets a single server-rendered Jinja template with inline CSS and JavaScript, so there is no second frontend build and no asset pipeline. It has a responsive grid with lazily loaded thumbnails, a lightbox with keyboard, swipe and filmstrip navigation, and light and dark themes. It references no external resources, because the page is often served on a network with no route to the internet.

Not included

Password protection, the share dialog in the desktop UI, and diagnostics for networks that block device-to-device traffic are follow-up work. Sharing over the internet is out of scope.

Testing

42 new tests covering the registry, the interface ranking and the route surface, with the full suite at 1044 passing. Verified end to end on three networks, with an album loaded on a phone over Wi-Fi.

Reviewing commit by commit is likely easiest, as each one is self-contained and they build in dependency order.

Summary by CodeRabbit

  • New Features
    • Added local-network album sharing with shareable URLs and optional expiration.
    • Added controls to create, view, list, and revoke active album shares.
    • Added responsive shared-album viewing with thumbnails, photo navigation, lightbox viewing, keyboard shortcuts, swipe support, dark mode, and localization.
    • Added automatic network-interface discovery and fallback port selection.
  • Documentation
    • Documented the new share-management API endpoints and response formats.
  • Tests
    • Added coverage for sharing, expiration, revocation, media access, and network discovery.

A naive gethostbyname picks the VMware adapter on a dev machine, so candidates are ranked by default route and the caller keeps an override.
Deliberately not a table: shares die with the process, so no token is ever written to disk and expiry needs no cleanup job.
An image is resolved to a path only after it is confirmed to belong to the shared album, so a token cannot read the whole images table.
The socket is bound before uvicorn sees it, because uvicorn raises SystemExit on a bind failure and would take the whole backend down.
Create, list and revoke stay on localhost. Schema names are share-specific so the generated OpenAPI does not rename the album models.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a59eff7d-46b3-427d-b529-49a49209b402

📥 Commits

Reviewing files that changed from the base of the PR and between 6a13459 and 9ffc915.

📒 Files selected for processing (1)
  • backend/tests/test_share_server.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/tests/test_share_server.py

Walkthrough

This change adds local-network album sharing. It adds management APIs, network discovery, an embedded share server, in-memory tokens, protected media routes, a responsive album viewer, lifecycle cleanup, tests, and OpenAPI documentation.

Changes

Local album sharing

Layer / File(s) Summary
Share contracts and network discovery
backend/app/schemas/share.py, backend/app/utils/network.py, backend/app/database/albums.py, backend/tests/test_network_utils.py, backend/tests/test_albums_db.py, docs/backend/backend_python/openapi.json
Defines share models, discovers and ranks IPv4 interfaces, validates album image membership, and documents the management API.
Share registry and server runtime
backend/app/config/settings.py, backend/app/share/registry.py, backend/app/share/server.py, backend/app/utils/share.py, backend/app/share/app.py, backend/main.py, backend/tests/test_share_registry.py, backend/tests/test_share_server.py
Stores expiring tokens, starts the embedded server with port fallback, coordinates creation and revocation, and stops the server during backend shutdown.
Share management API
backend/app/routes/share.py, backend/main.py
Adds interface listing, active-share listing, album share creation, and share revocation.
Token-based album viewer and media delivery
backend/app/share/*, backend/app/share/templates/album.html, backend/tests/test_share_routes.py
Adds isolated album and media routes. Validates tokens and album membership. Serves thumbnails and originals. Adds themes, lazy loading, lightbox controls, keyboard navigation, and touch navigation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ShareRoutes
  participant ShareServer
  participant ShareRegistry
  participant ShareViewer
  Client->>ShareRoutes: Create album share
  ShareRoutes->>ShareServer: Start embedded listener
  ShareRoutes->>ShareRegistry: Store album token
  ShareRoutes-->>Client: Return share metadata
  Client->>ShareViewer: Open token URL
  ShareViewer->>ShareRegistry: Resolve token
  ShareViewer-->>Client: Render album page
  Client->>ShareViewer: Request thumbnail or photo
  ShareViewer->>ShareRegistry: Validate token
  ShareViewer-->>Client: Serve album media
Loading

Possibly related PRs

Suggested labels: Python, Documentation

Poem

A rabbit checks the sharing gate,
Tokens keep the albums straight.
Thumbnails bloom and photos gleam,
Lightboxes guide the viewing stream.
Local paths stay close to home.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: sharing an album over the local network.
Linked Issues check ✅ Passed The implementation meets issue #1468 with tokenized in-memory shares, isolated FastAPI serving, media access, revocation, and a self-contained receiver page.
Out of Scope Changes check ✅ Passed The changes support local-network album sharing and do not add the excluded desktop UI, password protection, internet sharing, or network diagnostics.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (7)
backend/app/share/registry.py (3)

15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deprecated typing aliases in the three new share modules. Ruff reports UP035 at all three sites. The safe fix differs per file, because only two of the three modules have from __future__ import annotations.

  • backend/app/share/registry.py#L15-L15: the future import is present at line 9, so replace Dict/List with dict/list in the annotations at lines 39 and 86, and keep only Optional in the import.
  • backend/app/share/server.py#L14-L14: the future import is present at line 9, so replace List[str] at line 69 with list[str], and keep only Optional in the import.
  • backend/tests/test_share_routes.py#L5-L5: this module has no future import, and List[str] at line 58 is evaluated at runtime. Add from __future__ import annotations before switching to list[str], otherwise the annotation breaks on the Python 3.9 floor. Move Iterator to collections.abc in the same change.

Based on learnings: this repository targets Python 3.9 as a minimum, so builtin generics in runtime-evaluated annotations require from __future__ import annotations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/share/registry.py` at line 15, Replace deprecated typing aliases
in backend/app/share/registry.py#L15-L15 by using dict/list in the annotations
at lines 39 and 86, retaining only Optional in the import; apply the same change
in backend/app/share/server.py#L14-L14 for List[str] at line 69, retaining only
Optional. In backend/tests/test_share_routes.py#L5-L5, add the
future-annotations import before changing List[str] at line 58 to list[str], and
move Iterator to collections.abc; all three modules must remain compatible with
Python 3.9.

Sources: Learnings, Linters/SAST tools


30-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused unlock_tokens placeholder.

unlock_tokens appears only in the ShareEntry dataclass default and is never read or written. If you keep it, annotate the field as set[str]; otherwise remove it to avoid implying password/token unblocking support.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/share/registry.py` at line 30, Remove the unused unlock_tokens
field from the ShareEntry dataclass, since it is never read or written and
should not imply token-unblocking support.

99-102: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Stop the share listener when all shares expire.

get_shares returns expired entries from the raw _shares dict instead of using share_registry_list(), so share_registry_count() == 0 is currently only checked on explicit revocation. If a share expires and no request revokes it, the listener remains advertised/reachable with no live share to serve. Include the read/lookup paths in the shutdown check so the last expired share also closes the socket.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/share/registry.py` around lines 99 - 102, Update the share
registry read/lookup paths, especially get_shares and related accessors, to use
the live-share filtering in share_registry_list() and trigger the existing
shutdown behavior when no unexpired shares remain. Ensure expiration discovered
during reads causes the listener to close, while preserving normal lookup
behavior for active shares.
backend/tests/test_share_registry.py (1)

15-20: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Both new test modules omit return annotations. The guideline for backend/**/*.py requires annotated signatures and return types, and neither new test module follows it.

  • backend/tests/test_share_registry.py#L15-L20: annotate empty_registry as -> Iterator[None], and add -> None to every test method in TestCreate, TestLookup, TestRevoke, and TestListing.
  • backend/tests/test_share_routes.py#L28-L29: annotate the tmp_path parameter as pathlib.Path, parameterize the Iterator[dict] return as Iterator[dict[str, object]] or a TypedDict, and add -> None to every test method in TestViewer, TestTokenLifecycle, TestMedia, TestViewerChrome, and TestSurface.

As per coding guidelines: "In Python, annotate function signatures and return types".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_share_registry.py` around lines 15 - 20, Annotate all test
function signatures in backend/tests/test_share_registry.py (lines 15-20 and
every test in TestCreate, TestLookup, TestRevoke, and TestListing), including
empty_registry with -> Iterator[None] and each test with -> None. In
backend/tests/test_share_routes.py (lines 28-29 and every test in TestViewer,
TestTokenLifecycle, TestMedia, TestViewerChrome, and TestSurface), annotate
tmp_path as pathlib.Path, parameterize the fixture return as Iterator[dict[str,
object]] or a TypedDict, and add -> None to every test method.

Source: Coding guidelines

backend/app/share/routes.py (1)

22-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The public surface produces no log records.

logger is created at line 22 and never called. This is the only surface reachable from the local network, so an operator has no way to see who fetched what, or how often a bad token was tried. uvicorn access logging is also effectively off, because share_server_start in backend/app/share/server.py sets log_level="warning".

Log a rejected token at debug or info level, without the token value itself, so a failing share can be diagnosed without creating a token record in the log file.

💡 Sketch
 def _require_share(token: str) -> ShareEntry:
     entry = share_registry_get(token)
     if entry is None:
+        # The token itself stays out of the log: it is the only credential.
+        logger.info("Rejected a share request with an unknown or expired token")
         raise _not_found()
     return entry

Also applies to: 70-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/share/routes.py` at line 22, Update the rejected-token handling
in the share route around the affected lines 70–85 to call the module logger at
debug or info level, recording that authentication failed without including the
token value. Reuse the existing logger symbol and preserve the current rejection
response behavior.
backend/tests/test_share_routes.py (1)

135-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Three security-relevant or fallback branches have no test.

The suite is thorough on token lifecycle and cross-album refusal. Three paths in the code under review remain uncovered.

First, view_share in backend/app/share/routes.py lines 52-55 returns 404 when the album was deleted while shared. Delete album-1 and assert 404.

Second, share_media_resolve_path in backend/app/share/media.py lines 50-51 falls back to the original when thumbnailPath is missing. Insert an image with a null thumbnailPath and assert that the thumbnail route serves the original bytes.

Third, backend/app/share/server.py has no test at all. _bind_first_free walks up to five ports on collision, which is logic that can regress without notice.

As per path instructions: "Verify that all critical functionality is covered by tests".

💚 Sketch for the first two
def test_deleted_album_makes_the_token_meaningless(self, share_env):
    conn = sqlite3.connect(share_env["db_path"])
    conn.execute("DELETE FROM albums WHERE album_id = ?", ("album-1",))
    conn.commit()
    conn.close()
    assert share_env["client"].get(f"/s/{share_env['token']}").status_code == 404


def test_thumbnail_falls_back_to_the_original(self, share_env):
    conn = sqlite3.connect(share_env["db_path"])
    conn.execute("UPDATE images SET thumbnailPath = NULL WHERE id = ?", ("img-1",))
    conn.commit()
    conn.close()
    response = share_env["client"].get(f"/s/{share_env['token']}/thumb/img-1")
    assert response.status_code == 200
    assert response.content == JPEG_BYTES

Do you want me to write these tests, plus a lifecycle test for share_server_start and share_server_stop?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_share_routes.py` around lines 135 - 161, Extend the
share-route test coverage with cases for a deleted album returning 404 from the
shared album view and a null image thumbnailPath causing the thumbnail route to
serve the original bytes. Add server coverage for _bind_first_free, verifying it
advances through occupied ports and selects the first available one; cover
share_server_start and share_server_stop only as needed to exercise this
lifecycle behavior.

Source: Path instructions

backend/app/share/app.py (1)

16-27: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider adding baseline response headers on the network-exposed app.

The app serves HTML and image bytes to any browser that can route to the machine. Two cheap headers reduce the exposure.

X-Content-Type-Options: nosniff stops a browser from sniffing a served original into an executable type. A Content-Security-Policy that restricts default-src to 'self' matches the intent stated in the docstring of backend/app/share/templates/album.html, that the page loads no external resources. The inline <script> blocks in the template require script-src 'self' 'unsafe-inline', or a nonce.

The album name is already escaped by Jinja and covered by test_album_name_is_escaped, so this is defense in depth rather than a live exploit.

🛡️ Sketch
`@app.middleware`("http")
async def _harden(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["Referrer-Policy"] = "no-referrer"
    # The viewer is fully self-contained; nothing off-origin should load.
    response.headers["Content-Security-Policy"] = (
        "default-src 'self'; img-src 'self'; "
        "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"
    )
    return response
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/share/app.py` around lines 16 - 27, Add HTTP middleware in
create_share_app that applies baseline security headers to every response:
X-Content-Type-Options nosniff, Referrer-Policy no-referrer, and a
self-contained Content-Security-Policy permitting only same-origin resources
plus inline styles and scripts required by the album template. Preserve the
existing disabled documentation endpoints and share router registration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/routes/share.py`:
- Around line 52-72: The share route module currently owns database access, URL
construction, and share lifecycle orchestration. Keep the route handlers and
_to_share limited to HTTP parsing, validation, and response conversion; move
album/share database operations into app/database and move _to_share
URL/business orchestration plus create_share and revoke_share lifecycle logic
into app/utils, then have the routes delegate to those helpers while preserving
existing responses.
- Line 76: Update the response declarations for get_interfaces, get_shares, and
revoke_share to include HTTP 500 responses using the ShareErrorResponseEnvelope
model, while preserving their existing response declarations. Regenerate
docs/backend/backend_python/openapi.json so the OpenAPI specification reflects
these additions.
- Around line 116-118: Update the create_share function signature to annotate
body as CreateShareRequest | None while retaining Body(default=None), so the
optional request-body behavior matches its type annotation.
- Around line 162-165: Update the lifecycle logic around revoke_share and
create_share so the zero-share stop decision and share_server_stop() cannot race
with share creation. Keep the relevant lock held through the stop operation, or
otherwise coordinate pending starts and stops so a concurrent create cannot
leave an active share behind a stopped listener.

In `@backend/app/share/media.py`:
- Around line 40-41: Replace the full album fetch and set membership check in
share_media_resolve_path with a call to a new database-layer helper named
db_album_contains_image. Implement that helper in backend/app/database/albums.py
using a targeted album_images existence query for the album_id and image_id,
returning a boolean and closing the connection reliably.

In `@backend/app/share/server.py`:
- Around line 79-98: Supervise the task created by share_server_start: add the
reset and serve-result helpers, update the existing guard to verify _task is
still running and clear stale state before returning, and register a done
callback on _task that retrieves exceptions, logs failures, and resets _server,
_task, _sock, and _port. Preserve the existing successful startup and
port-return behavior.
- Around line 89-93: Update the embedded share server shutdown flow in
share_server_stop() to bound waiting for the server task with an explicit
timeout instead of unbounded asyncio.gather(). If shutdown relies on Uvicorn’s
timeout_graceful_shutdown, upgrade the pinned uvicorn dependency to a version
supporting that option and configure it in the Config construction.

In `@backend/app/share/templates/album.html`:
- Line 407: Update the lightbox keyboard handling in openLightbox and
closeLightbox so that, while the dialog is active, Tab and Shift+Tab cycle only
through focusable elements inside `#lightbox`, wrapping at the first and last
elements; remove the listener and preserve focus restoration when closing.
- Around line 529-534: Update the prefers-color-scheme listener setup near
applyTheme to feature-detect MediaQueryList.addEventListener and use the legacy
addListener API when unavailable. Preserve the existing change handler and
ensure applyTheme and subsequent viewer initialization still execute on older
Safari and WebViews.

In `@backend/app/utils/network.py`:
- Around line 95-98: Update the address filtering logic around the ip loop to
exclude only loopback addresses, allowing usable 169.254.0.0/16 link-local
candidates through. Add or adjust coverage in the network utility tests so a
link-local candidate is retained and can produce a share URL.
- Around line 52-59: Update the rank method to sort active interfaces before
default-route status, while preserving the existing virtual-interface and
interface-name tie-breakers. Add a test covering a down default-route candidate
versus an up non-default candidate, asserting the active interface is ranked
first.

In `@backend/tests/test_network_utils.py`:
- Around line 25-39: Annotate the fake_interfaces fixture and its returned
install callback with precise typing. Define the address and stats parameters as
mappings from interface-name strings to their respective adapter address and
stats values, and declare route_ip as Optional[str], including the callback
return type and fake_interfaces return type as appropriate.

---

Nitpick comments:
In `@backend/app/share/app.py`:
- Around line 16-27: Add HTTP middleware in create_share_app that applies
baseline security headers to every response: X-Content-Type-Options nosniff,
Referrer-Policy no-referrer, and a self-contained Content-Security-Policy
permitting only same-origin resources plus inline styles and scripts required by
the album template. Preserve the existing disabled documentation endpoints and
share router registration.

In `@backend/app/share/registry.py`:
- Line 15: Replace deprecated typing aliases in
backend/app/share/registry.py#L15-L15 by using dict/list in the annotations at
lines 39 and 86, retaining only Optional in the import; apply the same change in
backend/app/share/server.py#L14-L14 for List[str] at line 69, retaining only
Optional. In backend/tests/test_share_routes.py#L5-L5, add the
future-annotations import before changing List[str] at line 58 to list[str], and
move Iterator to collections.abc; all three modules must remain compatible with
Python 3.9.
- Line 30: Remove the unused unlock_tokens field from the ShareEntry dataclass,
since it is never read or written and should not imply token-unblocking support.
- Around line 99-102: Update the share registry read/lookup paths, especially
get_shares and related accessors, to use the live-share filtering in
share_registry_list() and trigger the existing shutdown behavior when no
unexpired shares remain. Ensure expiration discovered during reads causes the
listener to close, while preserving normal lookup behavior for active shares.

In `@backend/app/share/routes.py`:
- Line 22: Update the rejected-token handling in the share route around the
affected lines 70–85 to call the module logger at debug or info level, recording
that authentication failed without including the token value. Reuse the existing
logger symbol and preserve the current rejection response behavior.

In `@backend/tests/test_share_registry.py`:
- Around line 15-20: Annotate all test function signatures in
backend/tests/test_share_registry.py (lines 15-20 and every test in TestCreate,
TestLookup, TestRevoke, and TestListing), including empty_registry with ->
Iterator[None] and each test with -> None. In backend/tests/test_share_routes.py
(lines 28-29 and every test in TestViewer, TestTokenLifecycle, TestMedia,
TestViewerChrome, and TestSurface), annotate tmp_path as pathlib.Path,
parameterize the fixture return as Iterator[dict[str, object]] or a TypedDict,
and add -> None to every test method.

In `@backend/tests/test_share_routes.py`:
- Around line 135-161: Extend the share-route test coverage with cases for a
deleted album returning 404 from the shared album view and a null image
thumbnailPath causing the thumbnail route to serve the original bytes. Add
server coverage for _bind_first_free, verifying it advances through occupied
ports and selects the first available one; cover share_server_start and
share_server_stop only as needed to exercise this lifecycle behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c1b01dad-f640-4944-8649-10e98dd2bdde

📥 Commits

Reviewing files that changed from the base of the PR and between 5b4c41d and e149dfc.

📒 Files selected for processing (16)
  • backend/app/config/settings.py
  • backend/app/routes/share.py
  • backend/app/schemas/share.py
  • backend/app/share/__init__.py
  • backend/app/share/app.py
  • backend/app/share/media.py
  • backend/app/share/registry.py
  • backend/app/share/routes.py
  • backend/app/share/server.py
  • backend/app/share/templates/album.html
  • backend/app/utils/network.py
  • backend/main.py
  • backend/tests/test_network_utils.py
  • backend/tests/test_share_registry.py
  • backend/tests/test_share_routes.py
  • docs/backend/backend_python/openapi.json

Comment thread backend/app/routes/share.py Outdated
Comment on lines +52 to +72
def _to_share(entry: ShareEntry, port: int) -> Share:
album = db_get_album(entry.album_id)
return Share(
token=entry.token,
album_id=entry.album_id,
# An album deleted while shared leaves the token pointing at nothing;
# the viewer 404s, so say so here rather than failing the listing.
album_name=album["album_name"] if album else "(deleted album)",
image_count=len(db_get_album_images(entry.album_id)),
port=port,
created_at=entry.created_at.isoformat(),
expires_at=entry.expires_at.isoformat() if entry.expires_at else None,
urls=[
ShareUrl(
interface=candidate.interface,
ip=candidate.ip,
url=f"http://{candidate.ip}:{port}/s/{entry.token}",
)
for candidate in network_util_list_candidates()
],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move share orchestration out of the route module.

_to_share performs database access and share URL construction. create_share and revoke_share manage registry and server lifecycle state. Keep this module limited to HTTP parsing, validation, and response conversion. Move orchestration to app/utils/ and database operations to app/database/.

As per coding guidelines, “Routes handle HTTP and validation only; business logic belongs in app/utils/ and data access belongs in app/database/.”

Also applies to: 125-165

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 67-67: Do not make http calls without encryption
Context: f"http://{candidate.ip}:{port}/s/{entry.token}"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/routes/share.py` around lines 52 - 72, The share route module
currently owns database access, URL construction, and share lifecycle
orchestration. Keep the route handlers and _to_share limited to HTTP parsing,
validation, and response conversion; move album/share database operations into
app/database and move _to_share URL/business orchestration plus create_share and
revoke_share lifecycle logic into app/utils, then have the routes delegate to
those helpers while preserving existing responses.

Source: Coding guidelines

Comment thread backend/app/routes/share.py Outdated
Comment thread backend/app/routes/share.py
Comment thread backend/app/routes/share.py Outdated
Comment thread backend/app/share/media.py Outdated
Comment thread backend/app/share/templates/album.html
Comment thread backend/app/share/templates/album.html Outdated
Comment thread backend/app/utils/network.py Outdated
Comment thread backend/app/utils/network.py Outdated
Comment thread backend/tests/test_network_utils.py Outdated
Reading every image id per media request turned one page view into a quadratic number of row reads, plus a SQLite connection per thumbnail.
A serve task that died left the port set, so a share was handed out for a socket nothing was accepting on. Start and stop now hold one lock.
aria-modal claimed the page behind was inert while Tab still reached it. Older Safari lacks addEventListener on MediaQueryList, which killed the script before any thumbnail loaded.
Windows keeps a lease and a default route on a disconnected adapter, so ranking that first advertised an address nothing could reach. Link-local is now kept but ranked last rather than dropped.
Routes now handle HTTP only, with lifecycle and URL building in app/utils. Every share endpoint declares its 500 response and the optional request body is annotated as optional.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
backend/tests/test_network_utils.py (2)

102-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the optional candidate before member access.

network_util_best_candidate() returns Optional[InterfaceCandidate]. Store its result and assert that it is not None before accessing .ip or .is_default_route.

As per path instructions, ensure proper use of type hints.

Also applies to: 118-120, 172-172

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_network_utils.py` around lines 102 - 104, Update each
affected test using network_util_best_candidate() to assert the returned
candidate is not None before accessing its members, preserving the existing IP
and default-route assertions. Add or retain appropriate type narrowing so static
type checking recognizes the non-optional InterfaceCandidate after the
assertion.

Source: Path instructions


3-3: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use PEP 585 builtin generic syntax.

Replace Dict[...], List[...], and Iterator from typing with dict[...], list[...], and Iterator from collections.abc.

Proposed fix
-from typing import Dict, Iterator, List, Optional, Protocol
+from collections.abc import Iterator
+from typing import Optional, Protocol

Run pre-commit run --config .pre-commit-config.yaml --all-files from the repository root to validate the change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_network_utils.py` at line 3, Update the imports in
test_network_utils.py to use dict and list builtin generic syntax instead of
typing.Dict and typing.List, and import Iterator from collections.abc while
retaining Optional and Protocol from typing. Run the repository pre-commit
command to validate the change.

Sources: Coding guidelines, Learnings, Linters/SAST tools

backend/app/utils/network.py (1)

52-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Specify the rank tuple element types.

-> tuple does not describe the comparison contract. Use the concrete tuple type so static checks can detect incompatible rank values.

Proposed fix
-    def rank(self) -> tuple:
+    def rank(self) -> tuple[int, int, int, int, str]:

As per coding guidelines, annotate function signatures and return types accurately. As per path instructions, ensure proper use of type hints.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/utils/network.py` around lines 52 - 62, Update the NetworkAddress
rank property’s return annotation from generic tuple to a concrete tuple type
matching its returned elements: three integer ranking values followed by the
string interface name. Preserve the existing ordering logic and values.

Sources: Coding guidelines, Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/tests/test_albums_db.py`:
- Around line 266-292: Add the appropriate str type annotation to the test_db
parameter and explicit -> None return annotations on each
TestAlbumImageMembership test method shown, including the contains-image and
image-count tests; preserve their existing behavior.

In `@backend/tests/test_share_server.py`:
- Around line 67-70: Update the return type annotations of both test scenario
functions, including scenario near the shown
share_server_start/share_server_stop flow and the scenario around lines 91-94,
from Tuple[object, bool] to Tuple[Optional[int], bool]. Ensure Optional is
imported if needed, while preserving the existing scenario behavior.

---

Nitpick comments:
In `@backend/app/utils/network.py`:
- Around line 52-62: Update the NetworkAddress rank property’s return annotation
from generic tuple to a concrete tuple type matching its returned elements:
three integer ranking values followed by the string interface name. Preserve the
existing ordering logic and values.

In `@backend/tests/test_network_utils.py`:
- Around line 102-104: Update each affected test using
network_util_best_candidate() to assert the returned candidate is not None
before accessing its members, preserving the existing IP and default-route
assertions. Add or retain appropriate type narrowing so static type checking
recognizes the non-optional InterfaceCandidate after the assertion.
- Line 3: Update the imports in test_network_utils.py to use dict and list
builtin generic syntax instead of typing.Dict and typing.List, and import
Iterator from collections.abc while retaining Optional and Protocol from typing.
Run the repository pre-commit command to validate the change.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a63f68f5-6159-4c25-b66c-f1a769496bb0

📥 Commits

Reviewing files that changed from the base of the PR and between e149dfc and b9646d9.

📒 Files selected for processing (12)
  • backend/app/database/albums.py
  • backend/app/routes/share.py
  • backend/app/schemas/share.py
  • backend/app/share/media.py
  • backend/app/share/server.py
  • backend/app/share/templates/album.html
  • backend/app/utils/network.py
  • backend/app/utils/share.py
  • backend/tests/test_albums_db.py
  • backend/tests/test_network_utils.py
  • backend/tests/test_share_server.py
  • docs/backend/backend_python/openapi.json
🚧 Files skipped from review as they are similar to previous changes (5)
  • backend/app/schemas/share.py
  • backend/app/routes/share.py
  • backend/app/share/templates/album.html
  • docs/backend/backend_python/openapi.json
  • backend/app/share/media.py

Comment thread backend/tests/test_albums_db.py Outdated
Comment thread backend/tests/test_share_server.py Outdated
Concrete rank tuple, a TypedDict for the share description, and typing.List to match the rest of the backend. Drops an unused ShareEntry field that nothing read.
Return types and fixture parameter types throughout, a TypedDict for the share_env fixture, and narrowing before member access on optional returns.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
backend/app/share/media.py (1)

12-12: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace deprecated typing collection aliases.

Ruff reports UP035 for these new imports. Use built-in generics such as list[str] and tuple[...]. Import Iterator from collections.abc.

  • backend/app/share/media.py#L12-L12: replace List with list in share_media_image_ids.
  • backend/app/utils/network.py#L13-L13: replace List and Tuple with built-in generics.
  • backend/tests/test_share_registry.py#L2-L2: import Iterator from collections.abc.
  • backend/tests/test_share_routes.py#L5-L6: import Iterator from collections.abc and replace List[str] with list[str].
  • backend/tests/test_share_server.py#L2-L3: import Iterator from collections.abc and replace List and Tuple annotations.

Verify the update with the repository pre-commit command. Do not run ruff format.

As per coding guidelines, Ruff may be used as a linter and ruff format must not be used.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/share/media.py` at line 12, Replace deprecated typing collection
aliases across backend/app/share/media.py lines 12-12,
backend/app/utils/network.py lines 13-13, backend/tests/test_share_registry.py
lines 2-2, backend/tests/test_share_routes.py lines 5-6, and
backend/tests/test_share_server.py lines 2-3: use built-in list[...] and
tuple[...] annotations, import Iterator from collections.abc, and update
share_media_image_ids and the affected test annotations accordingly. Verify with
the repository pre-commit command; do not run ruff format.

Sources: Coding guidelines, Linters/SAST tools

backend/app/utils/share.py (1)

12-12: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace typing.List with the built-in generic.

List is deprecated in Ruff for Python 3.9+. Use list[ShareUrlRecord] in ShareDescription.urls and remove List from the import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/utils/share.py` at line 12, Replace the deprecated typing.List
usage in ShareDescription.urls with the built-in list[ShareUrlRecord]
annotation, and remove List from the typing import while preserving the existing
type semantics.

Sources: Learnings, Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/utils/share.py`:
- Around line 25-45: Move ShareUrlRecord and ShareDescription from
utils/share.py into schemas/share.py and update imports to use the shared schema
definitions. Relocate share_util_describe and its database/network orchestration
from utils/share.py into routes/share.py or the approved service layer, keeping
utils/share.py limited to pure helper logic and preserving existing behavior.

In `@backend/tests/test_share_server.py`:
- Around line 95-98: Replace the fixed asyncio.sleep in scenario with bounded
polling of the observable listener state, yielding across event-loop turns until
share_server_is_running() reports cleanup or the retry limit is reached. Keep
the retry bounded, then return share_server_port() and share_server_is_running()
for the existing final assertions.

---

Nitpick comments:
In `@backend/app/share/media.py`:
- Line 12: Replace deprecated typing collection aliases across
backend/app/share/media.py lines 12-12, backend/app/utils/network.py lines
13-13, backend/tests/test_share_registry.py lines 2-2,
backend/tests/test_share_routes.py lines 5-6, and
backend/tests/test_share_server.py lines 2-3: use built-in list[...] and
tuple[...] annotations, import Iterator from collections.abc, and update
share_media_image_ids and the affected test annotations accordingly. Verify with
the repository pre-commit command; do not run ruff format.

In `@backend/app/utils/share.py`:
- Line 12: Replace the deprecated typing.List usage in ShareDescription.urls
with the built-in list[ShareUrlRecord] annotation, and remove List from the
typing import while preserving the existing type semantics.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 91e643a2-4eae-46c7-b1ce-22df991d42b4

📥 Commits

Reviewing files that changed from the base of the PR and between b9646d9 and 6a13459.

📒 Files selected for processing (9)
  • backend/app/share/media.py
  • backend/app/share/registry.py
  • backend/app/utils/network.py
  • backend/app/utils/share.py
  • backend/tests/test_albums_db.py
  • backend/tests/test_network_utils.py
  • backend/tests/test_share_registry.py
  • backend/tests/test_share_routes.py
  • backend/tests/test_share_server.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/tests/test_network_utils.py
  • backend/tests/test_albums_db.py

Comment on lines +25 to +45
class ShareUrlRecord(TypedDict):
"""One reachable address for a share, as the desktop UI receives it."""

interface: str
ip: str
url: str


class ShareDescription(TypedDict):
"""Everything the desktop UI needs to present one share."""

token: str
album_id: str
album_name: str
image_count: int
port: int
created_at: str
expires_at: Optional[str]
urls: List[ShareUrlRecord]


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Preserve the backend layer boundaries.

ShareUrlRecord and ShareDescription are shared payload contracts. Define them in backend/app/schemas/share.py, not in backend/app/utils/share.py.

share_util_describe performs database reads and network discovery. Move this orchestration to backend/app/routes/share.py or the project's approved service layer. Keep backend/app/utils/share.py limited to pure helpers.

As per coding guidelines, routes belong in app/routes/, shared schemas belong in app/schemas/, database access belongs in app/database/, and pure helpers belong in app/utils/; do not collapse layers.

Also applies to: 75-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/utils/share.py` around lines 25 - 45, Move ShareUrlRecord and
ShareDescription from utils/share.py into schemas/share.py and update imports to
use the shared schema definitions. Relocate share_util_describe and its
database/network orchestration from utils/share.py into routes/share.py or the
approved service layer, keeping utils/share.py limited to pure helper logic and
preserving existing behavior.

Source: Coding guidelines

Comment thread backend/tests/test_share_server.py
A fixed 50ms delay would read a live listener under CI load and fail a callback that works. Polls the observable state to a bounded deadline.
@rohan-pandeyy

Copy link
Copy Markdown
Member Author

@coderabbitai review the changes, and also, mark those reviews as "completed" those which have been addressed.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="AOSSIE-Org/PictoPy"
pr="1469"

echo "== Pull request metadata =="
gh pr view "$pr" --repo "$repo" --json number,title,headRefName,baseRefName,reviewDecision,comments,reviews

echo
echo "== Changed files =="
gh pr diff "$pr" --repo "$repo" --name-only

echo
echo "== CodeRabbit review threads and replies =="
gh api graphql \
  -F owner="AOSSIE-Org" \
  -F name="PictoPy" \
  -F number="$pr" \
  -f query='
query($owner: String!, $name: String!, $number: Int!) {
  repository(owner: $owner, name: $name) {
    pullRequest(number: $number) {
      reviewThreads(first: 100) {
        nodes {
          id
          isResolved
          isOutdated
          path
          line
          originalLine
          comments(first: 50) {
            nodes {
              author { login }
              body
              createdAt
              url
            }
          }
        }
      }
    }
  }
}' \
  --jq '.data.repository.pullRequest.reviewThreads.nodes[] |
    select(any(.comments.nodes[]?; .author.login == "coderabbitai")) |
    {id, isResolved, isOutdated, path, line, originalLine, comments: [.comments.nodes[] | {author: .author.login, body, url}]}'

echo
echo "== Changed implementation structure =="
ast-grep outline backend/app/share --items all --type function,class
ast-grep outline backend/app/utils/share.py --items all --type function,class
ast-grep outline backend/app/routes/share.py --items all --type function,class

Length of output: 121415


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Applicable repository instructions =="
fd -a '^AGENTS\.md$|^\.coderabbit\.yaml$|^pyproject\.toml$|^requirements.*\.txt$' . \
  --exclude .git \
  --exec sh -c 'echo "--- $1"; sed -n "1,240p" "$1"' sh {}

echo
echo "== Current pull request diff =="
git diff --find-renames origin/main...HEAD -- \
  backend/app/config/settings.py \
  backend/app/database/albums.py \
  backend/app/routes/share.py \
  backend/app/schemas/share.py \
  backend/app/share \
  backend/app/utils/network.py \
  backend/app/utils/share.py \
  backend/main.py \
  backend/tests/test_albums_db.py \
  backend/tests/test_network_utils.py \
  backend/tests/test_share_registry.py \
  backend/tests/test_share_routes.py \
  backend/tests/test_share_server.py \
  | sed -n '1,2600p'

echo
echo "== Targeted current implementation =="
for file in \
  backend/app/routes/share.py \
  backend/app/utils/share.py \
  backend/app/share/server.py \
  backend/app/share/registry.py \
  backend/app/share/routes.py \
  backend/app/share/media.py \
  backend/app/share/app.py \
  backend/app/database/albums.py \
  backend/app/utils/network.py \
  backend/app/schemas/share.py \
  backend/main.py
do
  echo "--- $file"
  cat -n "$file"
done

echo
echo "== Share-related tests =="
for file in \
  backend/tests/test_share_server.py \
  backend/tests/test_share_registry.py \
  backend/tests/test_share_routes.py \
  backend/tests/test_network_utils.py \
  backend/tests/test_albums_db.py
do
  echo "--- $file"
  cat -n "$file"
done

Length of output: 221020


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="AOSSIE-Org/PictoPy"
thread_id="PRRT_kwDOJpiIC86XFCYT"

echo "== Resolve verified addressed CodeRabbit thread =="
gh api graphql \
  -f query='
mutation($threadId: ID!) {
  resolveReviewThread(input: {threadId: $threadId}) {
    thread {
      id
      isResolved
      path
      line
    }
  }
}' \
  -F threadId="$thread_id"

echo
echo "== Verify the two previously unresolved threads =="
gh api graphql \
  -F owner="AOSSIE-Org" \
  -F name="PictoPy" \
  -F number="1469" \
  -f query='
query($owner: String!, $name: String!, $number: Int!) {
  repository(owner: $owner, name: $name) {
    pullRequest(number: $number) {
      reviewThreads(first: 100) {
        nodes {
          id
          isResolved
          path
          line
          comments(first: 1) { nodes { url } }
        }
      }
    }
  }
}' \
  --jq '.data.repository.pullRequest.reviewThreads.nodes[] |
    select(.id == "PRRT_kwDOJpiIC86XFCYT" or .id == "PRRT_kwDOJpiIC86XGCHP")'

Length of output: 1774


@rohan-pandeyy I completed the manual review.

I marked the addressed optional request-body thread as completed.

I left the backend/app/utils/share.py layer-boundary thread open. The file still contains response-shaped TypedDict contracts and performs database and network orchestration.

I found one remaining issue:

  • Share expiry does not stop the listener. share_registry_list() and share_registry_count() filter expired entries, but neither removes them or invokes share_server_stop(). If the last share expires without an explicit revoke, the listener stays bound on the LAN port. Add expiry cleanup that coordinates with share_util_revoke() or a server lifecycle check. Add a test for the last share expiring without revocation.

The requested automated CodeRabbit review is temporarily rate-limited.

You are interacting with an AI system.

@rohan-pandeyy

Copy link
Copy Markdown
Member Author

@coderabbitai try again

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@rohan-pandeyy I will retry the automated review for PR #1469.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@rohan-pandeyy
rohan-pandeyy merged commit bc57ad9 into AOSSIE-Org:main Aug 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: Share an album over the local network

1 participant