Job cover images: move storage from disk into the database
Deploy to S3 / deploy (push) Successful in 31s Details

Production containers have ephemeral filesystems, so the disk-backed
image from the previous commit would vanish on redeploy. New
job_post_images table (bytea, PK = job_posts FK so re-upload replaces),
created everywhere by manual migration 009 which run_manual_sql applies
automatically at startup — prod boots with DB_AUTOGENERATE=false and
never autogenerates tables. Upload/fetch endpoints unchanged for the
frontend; fetch now serves bytes from the row. The one locally stored
disk image was imported into the table and backend/uploads removed.

E2E re-verified: create-with-image 200, fetch 200, cover renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/29/head
Talha Ahmed 2026-08-27 16:34:04 +05:00
parent e6a82aaa23
commit ccec7d48db
5 changed files with 101 additions and 41 deletions

2
.gitignore vendored
View File

@ -57,7 +57,7 @@ temp/
node_modules/ node_modules/
frontend/dist/ frontend/dist/
# Uploaded content (job cover images, …) — user data, never in git # Uploaded content — user data, never in git
backend/uploads/ backend/uploads/
**.pdf **.pdf

View File

@ -305,8 +305,8 @@ async def upload_job_image(
)), )),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Attach (or replace) the cover image of a job post. Stored on disk keyed """Attach (or replace) the cover image of a job post. Stored in the
by the post id; the create flow calls this right after /job/post-job.""" job_post_images table; the create flow calls this right after /job/post-job."""
try: try:
content=await file.read() content=await file.read()
service=JobPost(session=session) service=JobPost(session=session)
@ -328,14 +328,15 @@ async def fetch_job_image(
)), )),
session: AsyncSession = Depends(get_session), 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: try:
service=JobPost(session=session) service=JobPost(session=session)
path,media_type=await service.get_job_image(job_post_id) content,media_type=await service.get_job_image(job_post_id)
return FileResponse( return Response(
path=str(path), content=content,
media_type=media_type, media_type=media_type,
content_disposition_type="inline", headers={"Content-Disposition":"inline"},
) )
except HTTPException: except HTTPException:
raise raise

View File

@ -278,6 +278,50 @@ class JobPosts(SQLModel, table=True):
return await cls.get_job_post_by_id(session, record_id) 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): class SocialPlatform(SQLModel, table=True):
"""Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist.""" """Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist."""

View File

@ -9,7 +9,7 @@ from dotenv import load_dotenv
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator 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 ( from job.job_post.plugins import (
BufferError, BufferError,
create_buffer_post, create_buffer_post,
@ -25,32 +25,21 @@ from job.job_post.serializers import serialize_job_post, serialize_job_row
load_dotenv() load_dotenv()
logger=logging.getLogger("job.job_post") logger=logging.getLogger("job.job_post")
# Cover images are stored on disk keyed by the job post id — no DB column, so # Cover images live in the job_post_images table (bytea), NOT on disk:
# no migration. One image per post: uploading again replaces the previous file. # production containers have ephemeral filesystems, so a file-backed image
JOB_IMAGE_DIR=Path(os.getenv("JOB_IMAGE_DIR") or Path(__file__).resolve().parents[2]/"uploads"/"job_images") # would vanish on every redeploy. One row per post; re-upload replaces it.
IMAGE_EXT_BY_TYPE={"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/gif":"gif"} ALLOWED_IMAGE_TYPES={"image/png","image/jpeg","image/webp","image/gif"}
IMAGE_MEDIA_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","webp":"image/webp","gif":"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 MAX_JOB_IMAGE_BYTES=5*1024*1024
def _job_image_key(job_post_id) -> str: def _job_image_key(job_post_id) -> uuid.UUID:
"""The id is used as a filename — parse it as a UUID so a crafted value can
never traverse out of the image directory."""
try: try:
return str(uuid.UUID(str(job_post_id))) return uuid.UUID(str(job_post_id))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=422,detail="job_post_id must be a UUID") from 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): class JobPostCreate(BaseModel):
title: str title: str
experience_min: int | None = None experience_min: int | None = None
@ -254,33 +243,38 @@ class JobPost:
if not current_user: if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated") raise HTTPException(status_code=401,detail="Not authenticated")
key=_job_image_key(job_post_id) key=_job_image_key(job_post_id)
ext=IMAGE_EXT_BY_TYPE.get((content_type or "").lower()) media=(content_type or "").lower()
if not ext: if media not in ALLOWED_IMAGE_TYPES:
# Fall back to the filename extension; browsers occasionally send # Fall back to the filename extension; browsers occasionally send
# application/octet-stream for perfectly valid images. # application/octet-stream for perfectly valid images.
suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower() suffix=Path((filename or "").replace("\\","/")).suffix.lstrip(".").lower()
ext=suffix if suffix in IMAGE_MEDIA_BY_EXT else None media=IMAGE_TYPE_BY_EXT.get(suffix)
if not ext: if not media:
raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF") raise HTTPException(status_code=415,detail="Image must be PNG, JPG, WEBP or GIF")
if not content: if not content:
raise HTTPException(status_code=400,detail="Empty image upload") raise HTTPException(status_code=400,detail="Empty image upload")
if len(content)>MAX_JOB_IMAGE_BYTES: if len(content)>MAX_JOB_IMAGE_BYTES:
raise HTTPException(status_code=413,detail="Image must be under 5 MB") 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: if not total:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
JOB_IMAGE_DIR.mkdir(parents=True,exist_ok=True) raw_user=(current_user or {}).get("id")
# Replace, never accumulate: drop any previous image regardless of format. uploaded_by=uuid.UUID(str(raw_user)) if raw_user else None
for old_ext in IMAGE_MEDIA_BY_EXT: await JobPostImages.upsert(
(JOB_IMAGE_DIR/f"{key}.{old_ext}").unlink(missing_ok=True) self.session,key,
(JOB_IMAGE_DIR/f"{key}.{ext}").write_bytes(content) content_type=media,
return {"job_post_id":key,"has_image":True} 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): async def get_job_image(self,job_post_id):
path=find_job_image(job_post_id) key=_job_image_key(job_post_id)
if not path: row=await JobPostImages.get(self.session,key)
if not row:
raise HTTPException(status_code=404,detail="No image for this job post") 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): async def set_job_status(self,job_post_id,payload,current_user):
if not current_user: if not current_user:

View File

@ -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()
);