Skip to content

Commit 3e143ea

Browse files
chai-inuclaude
andauthored
upload: add the Upload model, migration and real crud (swap the stub) (#198)
* upload: add the Upload model, migration and real crud (swap the stub) Replaces the no-op crud_upload with the real persistence layer: the Upload model (one row per accepted upload; sha256 indexed but not unique), a named-FK migration, and real create/exists_with_sha/get_multi_by_sha/all_shas. find_orphans is now typed against models.Upload. Restores the deferred tests -- re-upload of identical bytes is free, a second row is written, find_orphans/detection work. * upload: close the purpose set, count avatars as usages, check config up front (#199) Four review findings on the image-upload work, none of which change the shape of the design. purpose was a free-form string. The karma gate tests for "figure" and the read-time misuse detection tests for "avatar", so any third value was an upload that was neither gated nor detected: karma 0 with purpose="banana" returned 200. It is now upload_purpose_T, a closed Literal beside editor_T, so anything else is a 422 at the boundary. find_usages scanned bodies only, but a purpose="avatar" upload lives in User.avatar_url and is embedded in no body at all, so every avatar ever uploaded read as an orphan -- and orphans are the input to any future garbage collection. Question.description had the same gap. Both avatar columns and both question description columns join the scan. is_configured() checked only the endpoint URL while the write path also needs the bucket (a NOT NULL column) and the public URL base, so a half-configured deployment 500'd *after* put_image had already stored the object. It now checks all three, which makes it the 503 it was meant to be, before anything is written. s15 shared one PNG fixture across checks, so the "new figure costs 2 coins" point was uploading bytes an earlier check had already sent -- correctly free, and reported as XFAIL against a backend that was right. Each check now builds its own bytes, and every point is a hard assertion rather than a note, so a regression fails the suite instead of printing a known-failure line. Claude-Session: https://claude.ai/code/session_01CqbhVfwzFUYGkgJWnWwSk1 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1d4859e commit 3e143ea

11 files changed

Lines changed: 638 additions & 171 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Add upload table
2+
3+
One row per accepted image upload; the object store holds one object per sha256.
4+
5+
Revision ID: 3c2f1a8b9d4e
6+
Revises: 7a670908f3fa
7+
Create Date: 2026-08-14 12:00:00.000000
8+
9+
"""
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision = '3c2f1a8b9d4e'
16+
down_revision = '7a670908f3fa'
17+
branch_labels = None
18+
depends_on = None
19+
20+
# Named, rather than the ``None`` autogenerate emits: an unnamed constraint
21+
# leaves the downgrade with no name to drop.
22+
FK_NAME = "upload_uploader_id_fkey"
23+
24+
25+
def upgrade():
26+
op.create_table(
27+
'upload',
28+
sa.Column('id', sa.Integer(), nullable=False),
29+
sa.Column('uploader_id', sa.Integer(), nullable=False),
30+
sa.Column('sha256', sa.String(), nullable=False),
31+
sa.Column('content_type', sa.String(), nullable=False),
32+
sa.Column('size_bytes', sa.Integer(), nullable=False),
33+
sa.Column('original_filename', sa.String(), nullable=True),
34+
sa.Column('purpose', sa.String(), nullable=False),
35+
sa.Column('storage_bucket', sa.String(), nullable=False),
36+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
37+
sa.ForeignKeyConstraint(['uploader_id'], ['user.id'], name=FK_NAME),
38+
sa.PrimaryKeyConstraint('id'),
39+
)
40+
op.create_index(op.f('ix_upload_id'), 'upload', ['id'], unique=False)
41+
op.create_index(op.f('ix_upload_uploader_id'), 'upload', ['uploader_id'], unique=False)
42+
op.create_index(op.f('ix_upload_sha256'), 'upload', ['sha256'], unique=False)
43+
44+
45+
def downgrade():
46+
op.drop_index(op.f('ix_upload_sha256'), table_name='upload')
47+
op.drop_index(op.f('ix_upload_uploader_id'), table_name='upload')
48+
op.drop_index(op.f('ix_upload_id'), table_name='upload')
49+
op.drop_constraint(FK_NAME, 'upload', type_='foreignkey')
50+
op.drop_table('upload')

‎chafan_core/app/api/api_v1/endpoints/upload.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from chafan_core.app.limiter import limiter
1010
from chafan_core.app.services import uploads as uploads_service
1111
from chafan_core.utils.base import HTTPException_
12+
from chafan_core.utils.constants import upload_purpose_T
1213

1314
router = APIRouter()
1415

@@ -22,7 +23,11 @@ def upload_image(
2223
ctx: RequestContext = Depends(deps.get_request_context_logged_in),
2324
file: UploadFile = File(...),
2425
file_size: int = Depends(valid_content_length),
25-
purpose: str = Form("figure"),
26+
# A closed set, not a free-form string: anything outside it would skip the
27+
# karma gate (which tests for "figure") and be invisible to the read-time
28+
# misuse detection (which tests for "avatar"). The default is the gated
29+
# value, so an old client cannot slip past the karma check either.
30+
purpose: upload_purpose_T = Form("figure"),
2631
) -> Any:
2732
if not object_storage.is_configured():
2833
raise HTTPException_(
Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,10 @@
1-
"""Stub persistence for image uploads.
2-
3-
TODO: replace with the real ``Upload`` model + migration. Until then the
4-
endpoint works against the object store but leaves no database record, and
5-
every upload looks new -- so re-uploading identical bytes is charged again and
6-
``find_orphans`` / ``misdeclared_avatars`` see nothing. The real crud, model
7-
and migration land together in the final "SQL" PR; this module is the seam that
8-
lets the rest of the code merge first.
9-
"""
10-
11-
from __future__ import annotations
12-
1+
import datetime
132
from typing import Any, List, Optional
143

154
from sqlalchemy.orm import Session
165

6+
from chafan_core.app.models.upload import Upload
7+
178

189
def create(
1910
db: Session,
@@ -25,21 +16,34 @@ def create(
2516
purpose: str,
2617
storage_bucket: str,
2718
original_filename: Optional[str] = None,
28-
) -> Any:
29-
# TODO: write an Upload row.
30-
return None
19+
) -> Upload:
20+
upload = Upload(
21+
uploader_id=uploader_id,
22+
sha256=sha256,
23+
content_type=content_type,
24+
size_bytes=size_bytes,
25+
original_filename=original_filename,
26+
purpose=purpose,
27+
storage_bucket=storage_bucket,
28+
created_at=datetime.datetime.now(tz=datetime.timezone.utc),
29+
)
30+
db.add(upload)
31+
db.flush()
32+
db.refresh(upload)
33+
return upload
3134

3235

3336
def exists_with_sha(db: Session, *, sha: str) -> bool:
34-
# TODO: query the Upload table. Until then every upload looks new.
35-
return False
37+
return db.query(Upload).filter(Upload.sha256 == sha).first() is not None
3638

3739

38-
def get_multi_by_sha(db: Session, *, sha: str) -> List[Any]:
39-
# TODO: return the Upload rows for `sha`.
40-
return []
40+
def get_multi_by_sha(db: Session, *, sha: str) -> List[Upload]:
41+
return db.query(Upload).filter(Upload.sha256 == sha).all()
4142

4243

4344
def all_shas(db: Session) -> List[str]:
44-
# TODO: return every distinct sha in the Upload table.
45-
return []
45+
return [row[0] for row in db.query(Upload.sha256).distinct().all()]
46+
47+
48+
def get(db: Session, id: Any) -> Optional[Upload]:
49+
return db.query(Upload).filter(Upload.id == id).first()

‎chafan_core/app/models/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .submission_suggestion import SubmissionSuggestion
3333
from .task import Task
3434
from .topic import Topic
35+
from .upload import Upload
3536
from .user import User
3637
from .webhook import Webhook
3738
from .viewcount import ViewCountArticle, ViewCountAnswer, ViewCountQuestion, ViewCountSubmission

‎chafan_core/app/models/upload.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from typing import TYPE_CHECKING, Optional
2+
3+
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
4+
from sqlalchemy.orm import relationship
5+
6+
from chafan_core.db.base_class import Base
7+
8+
if TYPE_CHECKING:
9+
from . import * # noqa: F401, F403
10+
11+
12+
class Upload(Base):
13+
"""One row per *accepted* upload; the bucket holds one object per sha256.
14+
15+
The two non-obvious columns:
16+
17+
* ``sha256`` is deliberately NOT unique. The bucket stores one object per
18+
sha; this table records one row per accepted upload, so a free re-upload
19+
still leaves an audit trail and the second person to upload the same
20+
bytes is not erased.
21+
* ``storage_bucket`` records which bucket holds those bytes, so a future
22+
vendor migration is a verifiable list rather than a guess.
23+
"""
24+
25+
id = Column(Integer, primary_key=True, index=True)
26+
uploader_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
27+
uploader: Optional["User"] = relationship("User", back_populates="uploads") # type: ignore
28+
sha256 = Column(String, nullable=False, index=True)
29+
content_type = Column(String, nullable=False)
30+
size_bytes = Column(Integer, nullable=False) # after sanitizing
31+
original_filename = Column(String)
32+
purpose = Column(String, nullable=False) # "figure" | "avatar"
33+
storage_bucket = Column(String, nullable=False)
34+
created_at = Column(DateTime(timezone=True), nullable=False)

‎chafan_core/app/models/user.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from chafan_core.app.models.submission import Submission
2626
from chafan_core.app.models.submission_suggestion import SubmissionSuggestion
2727
from chafan_core.app.models.task import Task
28+
from chafan_core.app.models.upload import Upload
2829
from chafan_core.db.base_class import Base
2930
from chafan_core.utils.base import UUID_LENGTH
3031
from chafan_core.utils.validators import StrippedNonEmptyBasicStr
@@ -247,6 +248,11 @@ class User(Base):
247248
back_populates="user",
248249
order_by="AuditLog.created_at.desc()",
249250
)
251+
uploads: List["Upload"] = relationship( # type: ignore
252+
"Upload",
253+
back_populates="uploader",
254+
order_by="Upload.created_at.desc()",
255+
)
250256
initiated_tasks: List["Task"] = relationship( # type: ignore
251257
"Task",
252258
back_populates="initiator",

‎chafan_core/app/object_storage.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,21 @@
3535

3636

3737
def is_configured() -> bool:
38-
return settings.UPLOADS_S3_ENDPOINT_URL is not None
38+
"""True when every setting the write path needs is present.
39+
40+
All three, not just the endpoint: the bucket is a NOT NULL column on the
41+
upload row and the public base is what the response URL is built from, so
42+
a half-configured deployment used to fail with a 500 *after* the object had
43+
already been stored. Checked up front, that becomes a 503 and nothing is
44+
written anywhere.
45+
"""
46+
return all(
47+
(
48+
settings.UPLOADS_S3_ENDPOINT_URL,
49+
settings.UPLOADS_S3_BUCKET,
50+
settings.UPLOADS_PUBLIC_URL_BASE,
51+
)
52+
)
3953

4054

4155
@lru_cache(maxsize=1)

‎chafan_core/app/services/uploads.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from chafan_core.app.common import MAX_UPLOAD_BYTES, report_msg
2222
from chafan_core.app.config import settings
2323
from chafan_core.utils.base import HTTPException_
24+
from chafan_core.utils.constants import upload_purpose_T
2425

2526
logger = logging.getLogger(__name__)
2627

@@ -44,7 +45,9 @@ def _read_bytes(file: Any) -> bytes:
4445
return bytes(buf)
4546

4647

47-
def upload_image(ctx, *, file, file_size: int, purpose: str) -> schemas.UploadedImage:
48+
def upload_image(
49+
ctx, *, file, file_size: int, purpose: upload_purpose_T
50+
) -> schemas.UploadedImage:
4851
current_user = ctx.get_current_active_user()
4952
db = ctx.get_db()
5053

@@ -98,39 +101,44 @@ def upload_image(ctx, *, file, file_size: int, purpose: str) -> schemas.Uploaded
98101
# the correct answer with no join table to maintain. Nothing here deletes.
99102
# ---------------------------------------------------------------------------
100103

101-
_BODY_COLUMNS = [
104+
# Every column that can hold the URL of an uploaded image. Bodies are the
105+
# obvious ones. The two avatar columns matter just as much: a purpose="avatar"
106+
# upload is never embedded in anyone's body, so without them every avatar ever
107+
# uploaded reads as an orphan -- and orphans are the input to any future
108+
# garbage collection.
109+
_REFERENCE_COLUMNS = [
102110
(models.Article, "body", "article"),
103111
(models.Article, "body_draft", "article"),
104112
(models.ArticleArchive, "body", "article_archive"),
105113
(models.Answer, "body", "answer"),
106114
(models.Answer, "body_draft", "answer"),
107115
(models.Archive, "body", "answer_archive"),
108116
(models.Comment, "body", "comment"),
117+
(models.Question, "description", "question"),
118+
(models.QuestionArchive, "description", "question_archive"),
109119
(models.Submission, "description", "submission"),
110120
(models.SubmissionArchive, "description", "submission_archive"),
121+
(models.User, "avatar_url", "user_avatar"),
122+
(models.User, "gif_avatar_url", "user_gif_avatar"),
111123
]
112124

113125

114126
def find_usages(db: Session, *, sha: str) -> List[str]:
115-
"""Locations (``table:id``) whose body text embeds ``sha``.
127+
"""Locations (``table:id``) that reference ``sha``.
116128
117129
The public URL embeds the sha, so a ``LIKE`` hit is exact.
118130
"""
119131
usages: List[str] = []
120-
for model, column, label in _BODY_COLUMNS:
132+
for model, column, label in _REFERENCE_COLUMNS:
121133
col = getattr(model, column)
122134
for (id_,) in db.query(model.id).filter(col.like(f"%{sha}%")).all():
123135
usages.append(f"{label}:{id_}")
124136
return usages
125137

126138

127-
def find_orphans(db: Session) -> List[Any]:
128-
"""Uploads whose sha appears in no body or archive text. Report only.
129-
130-
Returns nothing until the Upload table lands (``crud.upload`` is stubbed);
131-
typed ``Any`` so the real model can arrive later without touching this.
132-
"""
133-
orphans: List[Any] = []
139+
def find_orphans(db: Session) -> List[models.Upload]:
140+
"""Uploads whose sha appears in no body or archive text. Report only."""
141+
orphans: List[models.Upload] = []
134142
for sha in crud.upload.all_shas(db):
135143
if not find_usages(db, sha=sha):
136144
orphans.extend(crud.upload.get_multi_by_sha(db, sha=sha))

0 commit comments

Comments
 (0)