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/
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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