diff --git a/.gitignore b/.gitignore index 83a125d..424b7ec 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,7 @@ temp/ node_modules/ frontend/dist/ -# Uploaded content (job cover images, …) — user data, never in git +# Uploaded content — user data, never in git backend/uploads/ **.pdf diff --git a/backend/job/app.py b/backend/job/app.py index 9c6d2e6..034c28a 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -305,8 +305,8 @@ async def upload_job_image( )), session: AsyncSession = Depends(get_session), ): - """Attach (or replace) the cover image of a job post. Stored on disk keyed - by the post id; the create flow calls this right after /job/post-job.""" + """Attach (or replace) the cover image of a job post. Stored in the + job_post_images table; the create flow calls this right after /job/post-job.""" try: content=await file.read() service=JobPost(session=session) @@ -328,14 +328,15 @@ async def fetch_job_image( )), session: AsyncSession = Depends(get_session), ): - """The stored cover image, served inline; 404 when the post has none.""" + """The stored cover image, served inline from the database; 404 when the + post has none.""" try: service=JobPost(session=session) - path,media_type=await service.get_job_image(job_post_id) - return FileResponse( - path=str(path), + content,media_type=await service.get_job_image(job_post_id) + return Response( + content=content, media_type=media_type, - content_disposition_type="inline", + headers={"Content-Disposition":"inline"}, ) except HTTPException: raise diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 1f6277b..54169ad 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -278,6 +278,50 @@ class JobPosts(SQLModel, table=True): return await cls.get_job_post_by_id(session, record_id) +class JobPostImages(SQLModel, table=True): + """Cover image of a job post, stored as bytes IN the database. + + Deliberately not on disk: production containers have ephemeral filesystems, + so a file-backed image dies on every redeploy. One row per post — the PK is + the job_posts FK, which makes re-upload a plain replace. Created in prod by + migrations/manual/009_job_post_images.sql (autogen is off there).""" + + __tablename__ = "job_post_images" + + job_post_id: uuid.UUID = Field(primary_key=True, foreign_key="job_posts.id") + content_type: str + file_name: str | None = Field(default=None) + data: bytes + uploaded_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def get(cls, session: AsyncSession, job_post_id: uuid.UUID): + result = await session.execute(select(cls).where(cls.job_post_id == job_post_id)) + return result.scalars().first() + + @classmethod + async def upsert(cls, session: AsyncSession, job_post_id: uuid.UUID, *, + content_type: str, file_name: str | None, data: bytes, + uploaded_by: uuid.UUID | None): + row = await cls.get(session, job_post_id) + if row: + row.content_type = content_type + row.file_name = file_name + row.data = data + row.uploaded_by = uploaded_by + row.updated_at = _now() + else: + row = cls( + job_post_id=job_post_id, content_type=content_type, + file_name=file_name, data=data, uploaded_by=uploaded_by, + ) + session.add(row) + await session.commit() + return row + + class SocialPlatform(SQLModel, table=True): """Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist.""" diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 3a47a14..26bbfba 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator -from job.job_post.models import JobPosts,SocialPlatform +from job.job_post.models import JobPostImages,JobPosts,SocialPlatform from job.job_post.plugins import ( BufferError, create_buffer_post, @@ -25,32 +25,21 @@ from job.job_post.serializers import serialize_job_post, serialize_job_row load_dotenv() logger=logging.getLogger("job.job_post") -# Cover images are stored on disk keyed by the job post id — no DB column, so -# no migration. One image per post: uploading again replaces the previous file. -JOB_IMAGE_DIR=Path(os.getenv("JOB_IMAGE_DIR") or Path(__file__).resolve().parents[2]/"uploads"/"job_images") -IMAGE_EXT_BY_TYPE={"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/gif":"gif"} -IMAGE_MEDIA_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"image/gif"} +# Cover images live in the job_post_images table (bytea), NOT on disk: +# production containers have ephemeral filesystems, so a file-backed image +# would vanish on every redeploy. One row per post; re-upload replaces it. +ALLOWED_IMAGE_TYPES={"image/png","image/jpeg","image/webp","image/gif"} +IMAGE_TYPE_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"image/gif"} MAX_JOB_IMAGE_BYTES=5*1024*1024 -def _job_image_key(job_post_id) -> str: - """The id is used as a filename — parse it as a UUID so a crafted value can - never traverse out of the image directory.""" +def _job_image_key(job_post_id) -> uuid.UUID: try: - return str(uuid.UUID(str(job_post_id))) + return uuid.UUID(str(job_post_id)) except ValueError as e: raise HTTPException(status_code=422,detail="job_post_id must be a UUID") from e -def find_job_image(job_post_id) -> Path | None: - key=_job_image_key(job_post_id) - for ext in IMAGE_MEDIA_BY_EXT: - p=JOB_IMAGE_DIR/f"{key}.{ext}" - if p.exists(): - return p - return None - - class JobPostCreate(BaseModel): title: str experience_min: int | None = None @@ -254,33 +243,38 @@ class JobPost: if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") key=_job_image_key(job_post_id) - ext=IMAGE_EXT_BY_TYPE.get((content_type or "").lower()) - if not ext: + media=(content_type or "").lower() + if media not in ALLOWED_IMAGE_TYPES: # Fall back to the filename extension; browsers occasionally send # application/octet-stream for perfectly valid images. suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower() - ext=suffix if suffix in IMAGE_MEDIA_BY_EXT else None - if not ext: + media=IMAGE_TYPE_BY_EXT.get(suffix) + if not media: raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF") if not content: raise HTTPException(status_code=400,detail="Empty image upload") if len(content)>MAX_JOB_IMAGE_BYTES: raise HTTPException(status_code=413,detail="Image must be under 5 MB") - rows,total=await JobPosts.fetch_job_posts(self.session,ids=[key],active_only=False) + rows,total=await JobPosts.fetch_job_posts(self.session,ids=[str(key)],active_only=False) if not total: raise HTTPException(status_code=404,detail="Job post not found") - JOB_IMAGE_DIR.mkdir(parents=True,exist_ok=True) - # Replace, never accumulate: drop any previous image regardless of format. - for old_ext in IMAGE_MEDIA_BY_EXT: - (JOB_IMAGE_DIR/f"{key}.{old_ext}").unlink(missing_ok=True) - (JOB_IMAGE_DIR/f"{key}.{ext}").write_bytes(content) - return {"job_post_id":key,"has_image":True} + raw_user=(current_user or {}).get("id") + uploaded_by=uuid.UUID(str(raw_user)) if raw_user else None + await JobPostImages.upsert( + self.session,key, + content_type=media, + file_name=Path((filename or "").replace("\\","/")).name or None, + data=content, + uploaded_by=uploaded_by, + ) + return {"job_post_id":str(key),"has_image":True} async def get_job_image(self,job_post_id): - path=find_job_image(job_post_id) - if not path: + key=_job_image_key(job_post_id) + row=await JobPostImages.get(self.session,key) + if not row: raise HTTPException(status_code=404,detail="No image for this job post") - return path,IMAGE_MEDIA_BY_EXT[path.suffix.lstrip(".").lower()] + return row.data,row.content_type async def set_job_status(self,job_post_id,payload,current_user): if not current_user: diff --git a/backend/migrations/manual/009_job_post_images.sql b/backend/migrations/manual/009_job_post_images.sql new file mode 100644 index 0000000..36f4d8a --- /dev/null +++ b/backend/migrations/manual/009_job_post_images.sql @@ -0,0 +1,21 @@ +-- 009_job_post_images.sql +-- Cover images of job posts, stored IN the database (bytea) rather than on the +-- container filesystem, which is ephemeral in production — a disk-backed image +-- would vanish on every redeploy. One row per post: the PK doubles as the FK, +-- so a re-upload is a plain replace. 5 MB cap and type checks are enforced by +-- the API layer (backend/job/job_post/views.py save_job_image). +-- +-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql() +-- and recorded in manual_migrations. Matches the SQLModel JobPostImages model in +-- backend/job/job_post/models.py (needed here because prod boots with +-- DB_AUTOGENERATE=false and never autogenerates new tables). + +CREATE TABLE IF NOT EXISTS app.job_post_images ( + job_post_id uuid PRIMARY KEY REFERENCES app.job_posts(id) ON DELETE CASCADE, + content_type varchar NOT NULL, + file_name varchar, + data bytea NOT NULL, + uploaded_by uuid REFERENCES app.users(id), + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +);