From b7aa2aed3df1c10a52041f8aaa703259f9680775 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Apr 2026 00:18:42 +0200 Subject: [PATCH] fix: all users get subfolders, nobody owns the mount root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every user — including the initial admin — now gets their own subdirectory under PHOTO_DIRS (e.g. /photos/admin, /photos/bob). No one's source root points to the mount root itself, eliminating cross-user photo overlap entirely. - Setup endpoint: admin gets /photos/{username} like everyone else - Migration: default admin media_path set to /photos/admin - Remove scan directory pruning (no longer needed) - Fix thumbnail retry URL: use & separator when token query param already present (was producing ?token=...?retry=N) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../alembic/versions/0009_users_and_auth.py | 4 ++-- backend/app/routers/auth.py | 6 +++--- backend/app/tasks/scan.py | 20 ------------------- .../components/timeline/PhotoThumbnail.tsx | 3 ++- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/backend/alembic/versions/0009_users_and_auth.py b/backend/alembic/versions/0009_users_and_auth.py index 1948233..e35a7f3 100644 --- a/backend/alembic/versions/0009_users_and_auth.py +++ b/backend/alembic/versions/0009_users_and_auth.py @@ -80,7 +80,7 @@ def upgrade() -> None: pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") hashed = pwd_context.hash(generated_password) - # The default admin's media_path is the legacy /photos root + # Every user gets a subfolder — including the migrated admin. conn.execute( sa.text( "INSERT INTO users (id, username, hashed_password, role, media_path) " @@ -91,7 +91,7 @@ def upgrade() -> None: "username": "admin", "hashed": hashed, "role": "admin", - "media_path": "/photos", + "media_path": "/photos/admin", }, ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 91ac972..eed115f 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -161,9 +161,9 @@ async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)): if len(body.password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") - # The initial admin owns the entire photo mount root. Regular users - # (created later via admin panel) get a subdirectory under it. - media_path = settings.photo_dirs + # Every user — including the initial admin — gets their own subfolder + # under the photo mount root. Nobody owns the root directory itself. + media_path = os.path.join(settings.photo_dirs, body.username.strip()) os.makedirs(media_path, exist_ok=True) user = User( diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 4a38317..8fcbb55 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -164,28 +164,11 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta hidden_folder_cache[folder_row.id] = False return False - # Build a set of paths owned by OTHER users' source roots so - # the admin's scan of /photos doesn't descend into /photos/bob. - other_root_paths: set[str] = set() - if owner_user_id: - other_roots = (await session.execute( - select(SourceRoot.path) - .where(SourceRoot.is_active == True) # noqa: E712 - .where(SourceRoot.user_id != owner_user_id) - )).scalars().all() - other_root_paths = {os.path.normpath(p) for p in other_roots} - - def _should_skip_dir(dirpath: str) -> bool: - """True if dirpath is another user's source root.""" - return os.path.normpath(dirpath) in other_root_paths - # Pre-walk to compute the total file count upfront. Without this # the progress bar would jump every time a new subfolder is # encountered because the running total kept growing. total_files = 0 for _root, _dirs, files in os.walk(folder_path): - # Prune other users' directories from count - _dirs[:] = [d for d in _dirs if not _should_skip_dir(os.path.join(_root, d))] total_files += sum( 1 for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS ) @@ -196,9 +179,6 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta errors = [] for root, dirs, files in os.walk(folder_path): - # Skip directories that belong to other users' source roots - dirs[:] = [d for d in dirs if not _should_skip_dir(os.path.join(root, d))] - # Get or create folder entry folder = await get_or_create_folder(session, root, source_root_id, owner_user_id) progress_set(REDIS_KEY_CURRENT_FOLDER, root) diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index f9d6093..c387fdd 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -100,7 +100,8 @@ export function PhotoThumbnail({ // Cache-bust on retry so the browser actually re-requests instead of // serving the cached 404. const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium') - const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl + const sep = baseUrl.includes('?') ? '&' : '?' + const thumbnailUrl = retryCount > 0 ? `${baseUrl}${sep}retry=${retryCount}` : baseUrl // "Capture date probably wrong" — read straight from the stored // `has_date_warning` flag rather than recomputing the heuristic