Skip to content
Merged
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
39 changes: 37 additions & 2 deletions docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,49 @@ On Windows systems, setting environment variables can be done through the GUI as

## Limiting the Available Albums

You may wish to expose an instance of PhotoMapAI that only shows a subset of albums. To do this, run `start_photomap` with `--album-locked list,of,albums`. Use the album key(s) to select which albums to display, separating the keys with commas. Only these albums will then be available to users of the web application.
You may wish to expose an instance of PhotoMapAI that only shows a subset of albums. To do this, run `start_photomap` with the `--album-locked` option followed by one or more album keys separated by spaces.

### Single Album Lock

To lock PhotoMapAI to a single album, provide a single album key:

```bash
start_photomap --album-locked my_album
```

When a single album is locked:
- The album selection dropdown disappears from the settings dialog
- Only the locked album is accessible
- All album management features (adding, editing, deleting albums) are disabled
- File system operations (browsing directories, creating folders) are disabled
- The favorites export function is disabled

### Multiple Album Lock

To lock PhotoMapAI to multiple albums, provide multiple album keys separated by spaces:

```bash
start_photomap --album-locked album1 album2 album3
```

When multiple albums are locked:
- The album selection dropdown appears in the settings dialog showing only the locked albums
- Users can switch between the locked albums
- The "Manage Albums" button remains hidden
- All album management features remain disabled
- File system operations remain disabled
- The favorites export function remains disabled

### Using URL Parameters

It may also be handy to pair this with a specific URL that starts PhotoMapAI with a specific album. The format to start with an album named "my_album" is:

```bash
http://your.photomap.host/:8050?album=my_album
http://your.photomap.host:8050?album=my_album
```

When using multiple locked albums, the URL parameter allows users to select which of the locked albums to view initially. If the album specified in the URL is not in the locked list, the first locked album will be used instead.


## Running PhotoMapAI Under HTTPS

Expand Down
3 changes: 2 additions & 1 deletion photomap/backend/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ def get_args() -> argparse.Namespace:
parser.add_argument(
"--album-locked",
type=str,
nargs='+',
default=None,
help="Start with a specific locked in album and disable album management (default: None)",
help="Lock to specific album(s) and disable album management. Provide one or more album keys separated by spaces (default: None)",
)
parser.add_argument(
"--reload",
Expand Down
36 changes: 32 additions & 4 deletions photomap/backend/photomap_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from photomap.backend.args import get_args, get_version
from photomap.backend.config import get_config_manager
from photomap.backend.constants import get_package_resource_path
from photomap.backend.routers.album import album_router
from photomap.backend.routers.album import album_router, get_locked_albums
from photomap.backend.routers.filetree import filetree_router
from photomap.backend.routers.index import index_router
from photomap.backend.routers.search import search_router
Expand Down Expand Up @@ -65,11 +65,20 @@ async def get_root(
mode: Optional[str] = None,
):
"""Serve the main slideshow page."""
if os.environ.get("PHOTOMAP_ALBUM_LOCKED"):
album = os.environ.get("PHOTOMAP_ALBUM_LOCKED")
locked_albums = get_locked_albums()
if locked_albums:
album_locked = True
multiple_locked_albums = len(locked_albums) > 1

# If album is provided in URL, validate it's in the locked list
if album is not None and album in locked_albums:
pass # Use the album from URL
else:
# Default to the first locked album
album = locked_albums[0]
else:
album_locked = False
multiple_locked_albums = False
config_manager = get_config_manager()
if album is not None:
albums = config_manager.get_albums()
Expand All @@ -91,6 +100,7 @@ async def get_root(
"highWaterMark": high_water_mark,
"version": get_version(),
"album_locked": album_locked,
"multiple_locked_albums": multiple_locked_albums,
"inline_upgrades_allowed": inline_upgrades_allowed,
},
)
Expand Down Expand Up @@ -172,13 +182,31 @@ def main():
os.environ["PHOTOMAP_CONFIG"] = args.config.as_posix()

if args.album_locked:
os.environ["PHOTOMAP_ALBUM_LOCKED"] = args.album_locked
# Convert list of album keys to comma-separated string
os.environ["PHOTOMAP_ALBUM_LOCKED"] = ",".join(args.album_locked)

os.environ["PHOTOMAP_INLINE_UPGRADE"] = "1" if args.inline_upgrade else "0"

app_url = get_app_url(host, port)

config = get_config_manager()

# Validate that all locked albums exist in the configuration
if args.album_locked:
available_albums = config.get_albums()
if not available_albums:
logger.error("Error: No albums are configured in the configuration file.")
logger.error("Cannot lock to albums when no albums exist.")
sys.exit(1)

invalid_albums = [album for album in args.album_locked if album not in available_albums]
if invalid_albums:
logger.error(f"Error: The following album(s) specified in --album-locked do not exist in the configuration:")
for album in invalid_albums:
logger.error(f" - {album}")
logger.error(f"Available albums: {', '.join(available_albums.keys())}")
sys.exit(1)

logger.info(f"Using configuration file: {config.config_path}")
logger.info(f"Backend root directory: {repo_root}")
logger.info(
Expand Down
40 changes: 32 additions & 8 deletions photomap/backend/routers/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,41 @@ class LocationIQSetRequest(BaseModel):
config_manager = get_config_manager()


def get_locked_albums() -> Optional[List[str]]:
"""Get list of locked albums from environment variable.

Returns:
List of locked album keys, or None if no lock is set.
"""
locked_albums_str = os.environ.get("PHOTOMAP_ALBUM_LOCKED")
if not locked_albums_str:
return None
return [a.strip() for a in locked_albums_str.split(",")]


def check_album_lock(album_key: Optional[str] = None):
locked_album = os.environ.get("PHOTOMAP_ALBUM_LOCKED")
if album_key and locked_album and album_key != locked_album:
"""Check if album operations are allowed based on lock settings.

Args:
album_key: Optional album key to check. If None, checks if any modifications are allowed.

Raises:
HTTPException: If the operation is not allowed due to album lock.
"""
locked_albums = get_locked_albums()
if locked_albums is None:
return # No lock is set

if album_key and album_key not in locked_albums:
logger.warning(
f"Attempt to modify locked album configuration: {album_key} != {locked_album}"
f"Attempt to modify locked album configuration: {album_key} not in {locked_albums}"
)
raise HTTPException(
status_code=403,
detail=f"Album management is locked to album '{locked_album}' in this deployment.",
detail=f"Album management is locked to album(s) '{','.join(locked_albums)}' in this deployment.",
)

elif locked_album and not album_key:
elif not album_key:
logger.warning("Attempt to modify locked album configuration")
raise HTTPException(
status_code=403,
Expand All @@ -59,6 +82,8 @@ async def get_available_albums() -> List[Dict[str, Any]]:

if not albums:
return []

locked_albums = get_locked_albums()

return [
{
Expand All @@ -70,8 +95,7 @@ async def get_available_albums() -> List[Dict[str, Any]]:
"image_paths": album.image_paths,
}
for key, album in albums.items()
if os.environ.get("PHOTOMAP_ALBUM_LOCKED") is None
or key == os.environ.get("PHOTOMAP_ALBUM_LOCKED")
if locked_albums is None or key in locked_albums
]
except Exception as e:
logger.error(f"Failed to get albums: {e}")
Expand Down
4 changes: 3 additions & 1 deletion photomap/frontend/templates/modules/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,16 @@
{% endif %}

<!-- Album selection row -->
{% if not album_locked %}
{% if not album_locked or multiple_locked_albums %}
<div class="setting-row">
<label for="albumSelect">Album:</label>
<div class="album-selector-group">
<select id="albumSelect">
<option value="">Loading albums...</option>
</select>
{% if not album_locked %}
<button id="manageAlbumsBtn" class="btn-primary">Manage Albums</button>
{% endif %}
</div>
</div>
{% endif %}
Expand Down
83 changes: 83 additions & 0 deletions tests/backend/test_album_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ def setup_album_lock():
os.environ.pop("PHOTOMAP_ALBUM_LOCKED", None)


@pytest.fixture
def setup_multiple_album_lock():
"""Setup and teardown for multiple album lock tests."""
# Save original state
original_album_locked = os.environ.get("PHOTOMAP_ALBUM_LOCKED")

# Set multiple album lock
os.environ["PHOTOMAP_ALBUM_LOCKED"] = "test_album,another_album"

yield

# Restore original state
if original_album_locked:
os.environ["PHOTOMAP_ALBUM_LOCKED"] = original_album_locked
else:
os.environ.pop("PHOTOMAP_ALBUM_LOCKED", None)


def test_add_album_locked(client, setup_album_lock):
"""Test that /add_album is disabled when album is locked."""
new_album = create_album(
Expand Down Expand Up @@ -112,3 +130,68 @@ def test_routes_work_without_lock(client):
response = client.get("/filetree/directories")
# Should return 200 or 404 depending on path, but not 403
assert response.status_code != 403


def test_multiple_albums_locked_management_disabled(client, setup_multiple_album_lock):
"""Test that album management is disabled even with multiple locked albums."""
# Test that /add_album is disabled
new_album = create_album(
"new_album",
"New Album",
image_paths=["./tests/test_images"],
index="./tests/test_images/embeddings.npz",
umap_eps=0.1,
)
response = client.post("/add_album", json=new_album.model_dump())
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_multiple_albums_locked_filetree_disabled(client, setup_multiple_album_lock):
"""Test that filetree operations are disabled with multiple locked albums."""
# Test that /filetree/home is disabled
response = client.get("/filetree/home")
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_multiple_albums_access_allowed_album(client, setup_multiple_album_lock):
"""Test that accessing a locked album is allowed."""
# This test verifies that operations on locked albums don't raise 403
# when the album key is in the locked list
# Note: The actual behavior depends on the album existing in the config
pass


def test_multiple_albums_access_denied_non_locked_album(client, setup_multiple_album_lock):
"""Test that accessing a non-locked album is denied when albums are locked."""
# Try to get an album that's not in the locked list
response = client.get("/album/unlocked_album/")
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_validate_locked_albums_exist():
"""Test that the validation logic correctly identifies invalid album keys."""
from photomap.backend.config import get_config_manager

# Get the config manager with test config
config = get_config_manager()
available_albums = config.get_albums()

# Test with invalid albums - these should always be detected
invalid_albums = ["nonexistent_album_1", "nonexistent_album_2"]
invalid = [album for album in invalid_albums if album not in available_albums]
assert len(invalid) == len(invalid_albums), "All nonexistent albums should be detected as invalid"

# Test mixed valid and invalid (if albums exist in test config)
if available_albums:
valid_album = list(available_albums.keys())[0]
mixed = [valid_album, "nonexistent_album"]
invalid = [album for album in mixed if album not in available_albums]
assert len(invalid) == 1, "Should detect exactly one invalid album"
assert invalid[0] == "nonexistent_album"

# Verify valid albums pass validation
valid = [album for album in [valid_album] if album not in available_albums]
assert len(valid) == 0, "Valid album should pass validation"