Job cover images: move storage from disk into the database
Deploy to S3 / deploy (push) Successful in 31s
Details
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
parent
e6a82aaa23
commit
ccec7d48db
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
Loading…
Reference in New Issue