Fixed symlinks and thumbs. Added GID and UID to compose file
This commit is contained in:
+8
-3
@@ -3,14 +3,16 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
|
||||
# install system libs for Pillow (JPEG, PNG, WebP)
|
||||
# install system libs for Pillow (JPEG, PNG, WebP) and gosu for user switching
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget \
|
||||
libjpeg62-turbo zlib1g libpng16-16 libwebp7 wget gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV CONTENT_BASE_DIR=/library \
|
||||
PAGE_SIZE=50 \
|
||||
@@ -18,9 +20,12 @@ ENV CONTENT_BASE_DIR=/library \
|
||||
URL_PREFIX= \
|
||||
OPDS_BASIC_USER= \
|
||||
OPDS_BASIC_PASS= \
|
||||
ENABLE_WATCH=true
|
||||
ENABLE_WATCH=true \
|
||||
PUID=0 \
|
||||
PGID=0
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data", "/library"]
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-access-log", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
|
||||
+79
-4
@@ -170,6 +170,15 @@ def _index_progress(rel: str):
|
||||
|
||||
def _run_scan():
|
||||
"""Background scanner: writes into SQLite using its own connection."""
|
||||
global _LIBRARY_WATCHER
|
||||
|
||||
# Pause the watcher if it's running to avoid database lock conflicts
|
||||
watcher_was_running = False
|
||||
if _LIBRARY_WATCHER and _LIBRARY_WATCHER.observer and _LIBRARY_WATCHER.observer.is_alive():
|
||||
app_logger.warning("Pausing filesystem watcher during scan to avoid database locks")
|
||||
_LIBRARY_WATCHER.stop()
|
||||
watcher_was_running = True
|
||||
|
||||
conn = db.connect()
|
||||
try:
|
||||
db.begin_scan(conn)
|
||||
@@ -258,6 +267,15 @@ def _run_scan():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resume the watcher if it was running before the scan
|
||||
if watcher_was_running and ENABLE_WATCH:
|
||||
app_logger.warning("▶ Resuming filesystem watcher after scan completion")
|
||||
if _LIBRARY_WATCHER:
|
||||
_LIBRARY_WATCHER.start()
|
||||
else:
|
||||
_LIBRARY_WATCHER = watcher.LibraryWatcher(LIBRARY_DIR)
|
||||
_LIBRARY_WATCHER.start()
|
||||
|
||||
def _collect_cbz_rows() -> list[dict]:
|
||||
"""Fetch all file rows (is_dir=0, ext='cbz') with comicvineissue."""
|
||||
conn = db.connect()
|
||||
@@ -520,10 +538,16 @@ def _entry_xml_from_row(row) -> str:
|
||||
thumb_href_abs = None
|
||||
image_abs = None
|
||||
if (rget(row, "ext") or "").lower() == "cbz":
|
||||
# Try to get existing thumbnail, or generate it on-demand if needed
|
||||
p = have_thumb(rel, comicvine_issue) or generate_thumb(rel, abs_file, comicvine_issue)
|
||||
if p:
|
||||
image_abs = f"{base}{_abs_url('/thumb/' + quote(rel))}"
|
||||
# Link directly to the cached thumbnail file for better performance
|
||||
thumb_filename = p.name
|
||||
image_abs = f"{base}{_abs_url('/thumbs/' + thumb_filename)}"
|
||||
thumb_href_abs = image_abs
|
||||
app_logger.debug(f"Including thumbnail for {rel}: {image_abs}")
|
||||
else:
|
||||
app_logger.debug(f"No thumbnail available for {rel}")
|
||||
|
||||
return tpl.render(
|
||||
entry_id=f"{base}{_abs_url(download_href)}",
|
||||
@@ -796,10 +820,31 @@ def opds_search(query: str | None = Query(None, alias="query"),
|
||||
|
||||
# -------------------- File endpoints --------------------
|
||||
def _abspath(rel: str) -> Path:
|
||||
p = (LIBRARY_DIR / rel).resolve()
|
||||
if LIBRARY_DIR not in p.parents and p != LIBRARY_DIR:
|
||||
"""
|
||||
Resolve a relative path to an absolute path, supporting symlinks.
|
||||
|
||||
Security: Validates that the symlink itself is within LIBRARY_DIR structure
|
||||
before following it. This allows symlinks to point outside LIBRARY_DIR while
|
||||
keeping the library structure secure (users can't traverse outside LIBRARY_DIR).
|
||||
"""
|
||||
# Security check: the symlink/file path itself (not target) must be within LIBRARY_DIR
|
||||
requested_path = (LIBRARY_DIR / rel)
|
||||
|
||||
# Normalize the path to handle .. and . but DON'T follow symlinks yet
|
||||
try:
|
||||
# Use resolve() on parent and append the name to avoid following the final symlink
|
||||
if requested_path.parent != LIBRARY_DIR:
|
||||
normalized_parent = requested_path.parent.resolve(strict=False)
|
||||
if LIBRARY_DIR not in normalized_parent.parents and normalized_parent != LIBRARY_DIR:
|
||||
raise HTTPException(400, "Invalid path")
|
||||
normalized_path = requested_path.parent.resolve(strict=False) / requested_path.name
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(400, "Invalid path")
|
||||
return p
|
||||
|
||||
# Now follow symlinks to get the actual file
|
||||
resolved = normalized_path.resolve()
|
||||
|
||||
return resolved
|
||||
|
||||
def _common_file_headers(p: Path) -> dict:
|
||||
return {
|
||||
@@ -893,6 +938,7 @@ def stream(path: str, request: Request, range: str | None = Header(default=None)
|
||||
|
||||
@app.get("/thumb/{path:path}")
|
||||
def thumb(path: str, _=Depends(require_basic)):
|
||||
"""Generate thumbnail on-demand for a comic file."""
|
||||
abs_p = _abspath(path)
|
||||
if not abs_p.exists() or not abs_p.is_file():
|
||||
raise HTTPException(404)
|
||||
@@ -1251,6 +1297,35 @@ def thumbs_errors_log(_=Depends(require_basic)):
|
||||
headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
# NOTE: This catch-all route MUST come after all specific /thumbs/ routes above
|
||||
@app.head("/thumbs/{filename}")
|
||||
@app.get("/thumbs/{filename}")
|
||||
def cached_thumb(filename: str, request: Request, _=Depends(require_basic)):
|
||||
"""Serve pre-cached thumbnail files directly."""
|
||||
from pathlib import Path
|
||||
|
||||
# Log the request for debugging
|
||||
app_logger.info(f"Thumbnail request: {filename}")
|
||||
app_logger.info(f" User-Agent: {request.headers.get('user-agent', 'unknown')}")
|
||||
app_logger.info(f" Method: {request.method}")
|
||||
app_logger.info(f" Full URL: {request.url}")
|
||||
|
||||
thumbs_dir = Path("/data/thumbs")
|
||||
thumb_path = thumbs_dir / filename
|
||||
|
||||
# Security: ensure filename doesn't contain path traversal
|
||||
if not filename.endswith('.jpg') or '/' in filename or '..' in filename:
|
||||
app_logger.warning(f"Invalid thumbnail filename requested: {filename}")
|
||||
raise HTTPException(400, "Invalid thumbnail filename")
|
||||
|
||||
if not thumb_path.exists() or not thumb_path.is_file():
|
||||
app_logger.warning(f"Thumbnail not found: {thumb_path}")
|
||||
app_logger.info(f" Thumbs directory contents: {list(thumbs_dir.glob('*.jpg'))}")
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
|
||||
app_logger.info(f"Serving thumbnail: {thumb_path} ({thumb_path.stat().st_size} bytes)")
|
||||
return FileResponse(thumb_path, media_type="image/jpeg")
|
||||
|
||||
@app.get("/pages/cache/status", response_class=JSONResponse)
|
||||
def pages_cache_status(_=Depends(require_basic)):
|
||||
return JSONResponse(_page_cache_status())
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Get PUID and PGID from environment, default to root (0)
|
||||
PUID=${PUID:-0}
|
||||
PGID=${PGID:-0}
|
||||
|
||||
echo "Starting ComicOPDS with PUID=$PUID PGID=$PGID"
|
||||
|
||||
# If running as root (default), handle user/group creation and permissions
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Create group if it doesn't exist
|
||||
if ! getent group comicopds >/dev/null 2>&1; then
|
||||
groupadd -g "$PGID" comicopds 2>/dev/null || groupmod -g "$PGID" comicopds
|
||||
fi
|
||||
|
||||
# Create user if it doesn't exist
|
||||
if ! getent passwd comicopds >/dev/null 2>&1; then
|
||||
useradd -u "$PUID" -g "$PGID" -d /config -s /bin/bash comicopds 2>/dev/null || usermod -u "$PUID" -g "$PGID" comicopds
|
||||
fi
|
||||
|
||||
# Ensure /data directory exists with correct permissions
|
||||
# Create all subdirectories that the app will need
|
||||
mkdir -p /data/thumbs /data/pages
|
||||
|
||||
# Fix ownership of /data and all its contents (including any existing files from volume mount)
|
||||
# This ensures the application user can write to all directories
|
||||
if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
|
||||
echo "Setting ownership of /data to $PUID:$PGID"
|
||||
chown -R "$PUID:$PGID" /data
|
||||
echo "Running uvicorn as user comicopds (UID=$PUID, GID=$PGID)"
|
||||
exec gosu comicopds "$@"
|
||||
else
|
||||
# Running as root - just fix ownership without switching users
|
||||
chown -R "$PUID:$PGID" /data
|
||||
fi
|
||||
fi
|
||||
|
||||
# If we get here, either we're already non-root or PUID/PGID were 0
|
||||
echo "Running uvicorn as current user (UID=$(id -u), GID=$(id -g))"
|
||||
exec "$@"
|
||||
Reference in New Issue
Block a user