fix: all users get subfolders, nobody owns the mount root

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 00:18:42 +02:00
parent 180efb3eb0
commit b7aa2aed3d
4 changed files with 7 additions and 26 deletions

View File

@@ -80,7 +80,7 @@ def upgrade() -> None:
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
hashed = pwd_context.hash(generated_password) 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( conn.execute(
sa.text( sa.text(
"INSERT INTO users (id, username, hashed_password, role, media_path) " "INSERT INTO users (id, username, hashed_password, role, media_path) "
@@ -91,7 +91,7 @@ def upgrade() -> None:
"username": "admin", "username": "admin",
"hashed": hashed, "hashed": hashed,
"role": "admin", "role": "admin",
"media_path": "/photos", "media_path": "/photos/admin",
}, },
) )

View File

@@ -161,9 +161,9 @@ async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)):
if len(body.password) < 6: if len(body.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters") raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
# The initial admin owns the entire photo mount root. Regular users # Every user — including the initial admin — gets their own subfolder
# (created later via admin panel) get a subdirectory under it. # under the photo mount root. Nobody owns the root directory itself.
media_path = settings.photo_dirs media_path = os.path.join(settings.photo_dirs, body.username.strip())
os.makedirs(media_path, exist_ok=True) os.makedirs(media_path, exist_ok=True)
user = User( user = User(

View File

@@ -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 hidden_folder_cache[folder_row.id] = False
return 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 # Pre-walk to compute the total file count upfront. Without this
# the progress bar would jump every time a new subfolder is # the progress bar would jump every time a new subfolder is
# encountered because the running total kept growing. # encountered because the running total kept growing.
total_files = 0 total_files = 0
for _root, _dirs, files in os.walk(folder_path): 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( total_files += sum(
1 for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS 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 = [] errors = []
for root, dirs, files in os.walk(folder_path): 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 # Get or create folder entry
folder = await get_or_create_folder(session, root, source_root_id, owner_user_id) folder = await get_or_create_folder(session, root, source_root_id, owner_user_id)
progress_set(REDIS_KEY_CURRENT_FOLDER, root) progress_set(REDIS_KEY_CURRENT_FOLDER, root)

View File

@@ -100,7 +100,8 @@ export function PhotoThumbnail({
// Cache-bust on retry so the browser actually re-requests instead of // Cache-bust on retry so the browser actually re-requests instead of
// serving the cached 404. // serving the cached 404.
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium') 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 // "Capture date probably wrong" — read straight from the stored
// `has_date_warning` flag rather than recomputing the heuristic // `has_date_warning` flag rather than recomputing the heuristic