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
1 change: 1 addition & 0 deletions photomap/backend/routers/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ def validate_image_access(album_config, image_path: Path) -> bool:
@album_router.get("/filetree/home", tags=["File Management"])
async def get_home_directory():
"""Get the home directory path for the current user."""
check_album_lock() # May raise a 403 exception
# In a real application, you would determine the home directory based on the user's
# profile or configuration. Here, we just return a fixed path for demonstration.
try:
Expand Down
2 changes: 2 additions & 0 deletions photomap/backend/routers/curation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..embeddings import get_fps_indices_global, get_kmeans_indices_global, _open_npz_file
from ..config import get_config_manager
from ..progress import progress_tracker, IndexStatus
from .index import check_album_lock

router = APIRouter()
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -325,6 +326,7 @@ async def export_dataset(request: ExportRequest):
Returns:
JSON response with success count and any errors.
"""
check_album_lock() # May raise a 403 exception
# Validate and sanitize the output folder to prevent path traversal
if not request.output_folder:
raise HTTPException(status_code=400, detail="Output folder required")
Expand Down
1 change: 1 addition & 0 deletions photomap/backend/routers/filetree.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ async def get_directories(path: str = "", show_hidden: bool = False):
@filetree_router.get("/filetree/home", tags=["FileTree"])
async def get_home_directory():
"""Get the user's home directory path"""
check_album_lock() # May raise a 403 exception
try:
home_path = str(Path.home().resolve())
return JSONResponse(content={"homePath": home_path})
Expand Down
4 changes: 2 additions & 2 deletions photomap/frontend/static/javascript/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -793,9 +793,9 @@ class BookmarkManager {
}, false));

// Move, Download, and Delete (Move is before Download as requested)
menu.appendChild(makeButton(MOVE_SVG, "Move", () => this.moveBookmarkedImages(), !hasBookmarks));
menu.appendChild(makeButton(MOVE_SVG, "Move", () => this.moveBookmarkedImages(), !hasBookmarks || state.albumLocked));
menu.appendChild(makeButton(DOWNLOAD_SVG, "Download", () => this.downloadBookmarkedImages(), !hasBookmarks));
menu.appendChild(makeButton(DELETE_SVG, "Delete", () => this.deleteBookmarkedImages(), !hasBookmarks));
menu.appendChild(makeButton(DELETE_SVG, "Delete", () => this.deleteBookmarkedImages(), !hasBookmarks || state.albumLocked));

document.body.appendChild(menu);

Expand Down
49 changes: 49 additions & 0 deletions photomap/frontend/static/javascript/curation.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,23 @@ document.addEventListener('DOMContentLoaded', () => {
updateStarButtonState(); // Initialize star button state
});

// Wait for state to be initialized before applying album lock restrictions
window.addEventListener('stateReady', () => {
applyAlbumLockState(); // Apply album lock restrictions after state is ready
});

// Validate export path and enable/disable export button
function validateExportPath() {
const exportPathInput = document.getElementById('curationExportPath');
const exportBtn = document.getElementById('curationExportBtn');
if (!exportPathInput || !exportBtn) return;

// If album is locked, always disable
if (state.albumLocked) {
exportBtn.disabled = true;
return;
}

const path = exportPathInput.value.trim();
// Enable export button only if path is not empty and we have a selection
const hasSelection = currentSelectionIndices.size > 0;
Expand All @@ -143,6 +154,36 @@ function updateStarButtonState() {
setFavoritesBtn.disabled = currentSelectionIndices.size === 0;
}

// Apply album lock restrictions to UI elements
function applyAlbumLockState() {
if (!state.albumLocked) return;

const exportPathInput = document.getElementById('curationExportPath');
const exportBtn = document.getElementById('curationExportBtn');
const browseBtn = document.getElementById('curationBrowseBtn');

if (exportPathInput) {
exportPathInput.disabled = true;
exportPathInput.placeholder = 'Disabled (Album Locked)';
exportPathInput.style.backgroundColor = '#333';
exportPathInput.style.color = '#666';
}

if (exportBtn) {
exportBtn.disabled = true;
exportBtn.title = 'Export disabled when album is locked';
}

if (browseBtn) {
browseBtn.disabled = true;
browseBtn.style.opacity = '0.5';
browseBtn.style.filter = 'grayscale(100%)';
browseBtn.style.cursor = 'not-allowed';
browseBtn.style.backgroundColor = '#333';
browseBtn.title = 'Browse disabled when album is locked';
}
}

function setupEventListeners() {
const slider = document.getElementById('curationSlider');
const number = document.getElementById('curationNumber');
Expand Down Expand Up @@ -183,6 +224,9 @@ function setupEventListeners() {
// Browse button - open file tree
if (browseBtn) {
browseBtn.onclick = () => {
if (state.albumLocked) {
return; // Do nothing if album is locked
}
const currentPath = exportPathInput.value || '';
createSimpleDirectoryPicker((selectedPath) => {
exportPathInput.value = selectedPath;
Expand Down Expand Up @@ -464,6 +508,11 @@ function setupEventListeners() {
// Export

exportBtn.onclick = async () => {
if (state.albumLocked) {
alert('Export is disabled when album is locked.');
return;
}

const path = document.getElementById('curationExportPath').value;
if (!path) { alert("Please enter path."); return; }

Expand Down
5 changes: 5 additions & 0 deletions photomap/frontend/static/javascript/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const state = {
suppressDeleteConfirm: false, // Flag to suppress delete confirmation dialogs
gridThumbSizeFactor: 1.0, // Scaling factor for grid thumbnails
swiper: null, // backwards compatibility hack; contains the single_swiper.swiper instance
albumLocked: false, // Whether album management is locked
// persisted search settings
minSearchScore: 0.2, // [0.0, 1.0]
maxSearchResults: 100, // [50, 500]
Expand Down Expand Up @@ -48,6 +49,10 @@ export function initializeFromServer() {
if (window.slideshowConfig?.album !== null) {
setAlbum(window.slideshowConfig.album);
}

if (window.slideshowConfig?.albumLocked !== undefined) {
state.albumLocked = window.slideshowConfig.albumLocked;
}
}

// Restore state from local storage
Expand Down
3 changes: 2 additions & 1 deletion photomap/frontend/templates/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
currentDelay: {% if delay %}{{ delay }}{% else %}null{% endif %},
mode: {% if mode %}"{{ mode }}"{% else %}null{% endif %},
highWaterMark: {% if highWaterMark %}{{ highWaterMark }}{% else %}null{% endif %},
album: {% if album %}"{{ album }}"{% else %}null{% endif %}
album: {% if album %}"{{ album }}"{% else %}null{% endif %},
albumLocked: {% if album_locked %}true{% else %}false{% endif %}
};
</script>
<script type="module" src="static/main.js"></script>
Expand Down
114 changes: 114 additions & 0 deletions tests/backend/test_album_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""
test_album_lock.py
Tests for the album lock functionality of the PhotoMap application.
When album-locked is set, various file management API routes should be disabled.
"""

import os
import pytest
from fixtures import client
from photomap.backend.config import create_album


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

# Set album lock
os.environ["PHOTOMAP_ALBUM_LOCKED"] = "test_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(
"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_update_album_locked(client, setup_album_lock):
"""Test that /update_album is disabled when album is locked."""
album_data = {
"key": "test_album",
"name": "Test Album",
"image_paths": ["./tests/test_images"],
"index": "./tests/test_images/embeddings.npz",
"umap_eps": 0.1,
"description": "Updated album"
}
response = client.post("/update_album", json=album_data)
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_delete_album_locked(client, setup_album_lock):
"""Test that /delete_album is disabled when album is locked."""
response = client.delete("/delete_album/test_album")
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_filetree_home_locked(client, setup_album_lock):
"""Test that /filetree/home is disabled when album is locked."""
response = client.get("/filetree/home")
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_filetree_directories_locked(client, setup_album_lock):
"""Test that /filetree/directories is disabled when album is locked."""
response = client.get("/filetree/directories")
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_filetree_create_directory_locked(client, setup_album_lock):
"""Test that /filetree/create_directory is disabled when album is locked."""
response = client.post("/filetree/create_directory", json={
"parent_path": "/tmp",
"directory_name": "test_dir"
})
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_curation_export_locked(client, setup_album_lock):
"""Test that /api/curation/export is disabled when album is locked."""
response = client.post("/api/curation/export", json={
"filenames": ["test.jpg"],
"output_folder": "/tmp/export"
})
assert response.status_code == 403
assert "Album management is locked" in response.json()["detail"]


def test_routes_work_without_lock(client):
"""Test that routes work normally when album is not locked."""
# Ensure no album lock is set
os.environ.pop("PHOTOMAP_ALBUM_LOCKED", None)

# Test that /filetree/home works
response = client.get("/filetree/home")
assert response.status_code == 200
assert "homePath" in response.json()

# Test that /filetree/directories works
response = client.get("/filetree/directories")
# Should return 200 or 404 depending on path, but not 403
assert response.status_code != 403