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
6 changes: 4 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ RUN pip install --no-cache-dir -r requirements.txt && \
COPY . .
RUN chown -R appuser:appgroup /app

# Create gallery-dl config directory under /app (accessible by any user)
# Create gallery-dl config directory under /app (owner: appuser, no world access)
RUN mkdir -p /app/config && \
echo '{}' > /app/config/gallery-dl.json && \
chmod 777 /app/config /app/config/gallery-dl.json
chown -R appuser:appgroup /app/config && \
chmod 750 /app/config && \
chmod 640 /app/config/gallery-dl.json

USER appuser

Expand Down
3 changes: 2 additions & 1 deletion backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ class Settings(BaseSettings):
# gallery-dl config (bind-mounted)
gallery_dl_config: str = "/app/config/gallery-dl.json"

# Pixiv OAuth
# Pixiv OAuth (public Android app credentials; override via env if needed)
pixiv_client_id: str = "MOBrBDS8blbauoSck0ZfDbtuzpyT"
pixiv_client_secret: str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"
# To override: set PIXIV_CLIENT_ID and PIXIV_CLIENT_SECRET in .env

model_config = {"env_file": ".env", "case_sensitive": False}

Expand Down
48 changes: 47 additions & 1 deletion backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
LargeBinary,
SmallInteger,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
Expand Down Expand Up @@ -174,7 +175,52 @@ class ApiToken(Base):
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str | None] = mapped_column(Text)
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
token_plain: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_used_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True))


class BrowseHistory(Base):
__tablename__ = "browse_history"
__table_args__ = (UniqueConstraint("user_id", "source", "source_id"),)

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
source: Mapped[str] = mapped_column(Text, nullable=False)
source_id: Mapped[str] = mapped_column(Text, nullable=False)
title: Mapped[str | None] = mapped_column(Text)
thumb: Mapped[str | None] = mapped_column(Text)
gid: Mapped[int | None] = mapped_column(BigInteger)
token: Mapped[str | None] = mapped_column(Text)
viewed_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now())


class SavedSearch(Base):
__tablename__ = "saved_searches"

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
query: Mapped[str] = mapped_column(Text, default="")
params: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now())


class TagTranslation(Base):
__tablename__ = "tag_translations"
__table_args__ = (UniqueConstraint("namespace", "name", "language"),)

namespace: Mapped[str] = mapped_column(Text, primary_key=True)
name: Mapped[str] = mapped_column(Text, primary_key=True)
language: Mapped[str] = mapped_column(Text, primary_key=True, default="zh")
translation: Mapped[str] = mapped_column(Text, nullable=False)


class BlockedTag(Base):
__tablename__ = "blocked_tags"
__table_args__ = (UniqueConstraint("user_id", "namespace", "name"),)

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
namespace: Mapped[str] = mapped_column(Text, nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
eh,
export,
external,
history,
import_router,
library,
search,
Expand Down Expand Up @@ -78,6 +79,7 @@ async def lifespan(app: FastAPI):
app.include_router(import_router.router, prefix="/api/import")
app.include_router(export.router, prefix="/api/export")
app.include_router(external.router, prefix="/api/external/v1")
app.include_router(history.router, prefix="/api/history")


@app.get("/api/health")
Expand Down
58 changes: 47 additions & 11 deletions backend/routers/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,17 @@ async def enqueue_download(
_: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""Create a DB download record and enqueue an ARQ job."""
"""Create a DB download record and enqueue an ARQ job.

Order: ARQ enqueue first, then DB commit. If ARQ fails we never create the
DB record. If DB insert fails after a successful ARQ enqueue we log a
warning — the ARQ job will time out naturally without a matching DB record.
"""
job_id = uuid.uuid4()
source = _detect_source(req.url)

# DB record + ARQ enqueue in one logical unit
initial_progress = {"total": req.total} if req.total is not None else None
job = DownloadJob(id=job_id, url=req.url, source=source, status="queued", progress=initial_progress)
db.add(job)
await db.flush()

# 1. Enqueue ARQ job first — if this fails, no DB record is created.
arq = request.app.state.arq
try:
await arq.enqueue_job(
Expand All @@ -67,11 +68,24 @@ async def enqueue_download(
req.total,
_job_id=str(job_id),
)
except Exception:
await db.rollback()
except Exception as exc:
logger.error("[enqueue] ARQ enqueue failed: %s", exc)
raise HTTPException(status_code=503, detail="Failed to enqueue download job")

await db.commit()
# 2. Persist DB record. If this fails, log a warning; the ARQ job will
# eventually time out without a matching DB row.
try:
job = DownloadJob(id=job_id, url=req.url, source=source, status="queued", progress=initial_progress)
db.add(job)
await db.commit()
except Exception as exc:
logger.warning(
"[enqueue] ARQ job %s enqueued but DB insert failed: %s — job will time out naturally",
job_id,
exc,
)
raise HTTPException(status_code=500, detail="Job enqueued but failed to persist to database")

return {"job_id": str(job_id), "status": "queued"}


Expand Down Expand Up @@ -164,6 +178,18 @@ async def pause_resume_job(
except (ValueError, TypeError):
raise HTTPException(status_code=500, detail="Corrupted PID value in Redis")

# Validate that the PID actually belongs to a gallery-dl process before signalling.
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
cmdline = fh.read()
if b"gallery-dl" not in cmdline:
logger.warning("[pause_resume] pid %d cmdline does not contain gallery-dl; clearing stale PID", pid)
await redis.delete(f"download:pid:{job_id}")
raise HTTPException(status_code=400, detail="Process is no longer a gallery-dl process")
except FileNotFoundError:
await redis.delete(f"download:pid:{job_id}")
raise HTTPException(status_code=400, detail="Process no longer exists")

try:
if body.action == "pause":
if job.status != "running":
Expand All @@ -176,6 +202,7 @@ async def pause_resume_job(
os.kill(pid, signal.SIGCONT)
job.status = "running"
except ProcessLookupError:
await redis.delete(f"download:pid:{job_id}")
raise HTTPException(status_code=400, detail="Process no longer exists")
except PermissionError:
raise HTTPException(status_code=500, detail="Insufficient permission to signal process")
Expand All @@ -202,8 +229,17 @@ async def cancel_job(
if pid_bytes:
try:
pid = int(pid_bytes)
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, ValueError, PermissionError) as exc:
# Verify PID belongs to gallery-dl before sending signal
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
cmdline = fh.read()
if b"gallery-dl" not in cmdline:
logger.warning("[cancel] pid %d is not gallery-dl; skipping signal", pid)
else:
os.kill(pid, signal.SIGTERM)
except FileNotFoundError:
pass # Process already gone
except (ValueError, PermissionError) as exc:
logger.warning("[cancel] failed to kill pid %s: %s", pid_bytes, exc)
await redis.delete(f"download:pid:{job_id}")

Expand Down
Loading
Loading