6 Commits

Author SHA1 Message Date
c210978261 Chore: Revert initContainer command after successful migration 2026-05-09 20:39:22 -04:00
a61c67614f Chore: Bump manifests and add migration init container sequence 2026-05-09 20:26:51 -04:00
27425889b3 Fix: Include scripts/ in production Docker image
Dockerfile.prod explicitly listed copied directories and omitted
scripts/, so the migration script was absent from the prod image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:18:48 +00:00
61d923d5be Feat: Replace UUID image identifiers with 8-character base62 short IDs
Short IDs become the canonical identifier in URLs (/i/:short_id),
MinIO/R2 storage keys, and all API responses. Hash-based deduplication
is preserved. Includes two-phase Alembic migration (003 adds nullable
column, 004 enforces NOT NULL) with a backfill script to copy storage
objects and populate short_id for existing images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:13:55 +00:00
87eb2703f5 Chore: Bump manifests for v1.3.1 2026-05-09 18:43:33 -04:00
bc0f5173c0 Feat: Substring tag search — match anywhere in tag name
Changes prefix-only LIKE to case-insensitive ILIKE with leading
wildcard so queries like "at" now match "cat", "scatter", etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:42:23 +00:00
44 changed files with 1450 additions and 141 deletions

View File

@@ -1 +1 @@
{"feature_directory":"specs/016-copy-url-toast"}
{"feature_directory":"specs/017-short-id-migration"}

View File

@@ -1,5 +1,5 @@
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan at
`specs/016-copy-url-toast/plan.md`.
`specs/017-short-id-migration/plan.md`.
<!-- SPECKIT END -->

View File

@@ -37,6 +37,7 @@ COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
COPY --chown=appuser:appgroup app/ ./app/
COPY --chown=appuser:appgroup alembic/ ./alembic/
COPY --chown=appuser:appgroup alembic.ini .
COPY --chown=appuser:appgroup scripts/ ./scripts/
USER appuser

View File

@@ -0,0 +1,24 @@
"""add short_id column to images
Revision ID: 003
Revises: 002
Create Date: 2026-05-09
"""
from alembic import op
import sqlalchemy as sa
revision = "003"
down_revision = "002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("images", sa.Column("short_id", sa.String(8), nullable=True))
op.create_index("ix_images_short_id", "images", ["short_id"], unique=True)
def downgrade() -> None:
op.drop_index("ix_images_short_id", table_name="images")
op.drop_column("images", "short_id")

View File

@@ -0,0 +1,24 @@
"""set short_id NOT NULL on images
Revision ID: 004
Revises: 003
Create Date: 2026-05-09
IMPORTANT: Run migrate_to_short_ids.py script BEFORE applying this migration.
This migration will fail if any rows still have short_id IS NULL.
"""
from alembic import op
revision = "004"
down_revision = "003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("images", "short_id", nullable=False)
def downgrade() -> None:
op.alter_column("images", "short_id", nullable=True)

View File

@@ -92,9 +92,7 @@ class LoginRateLimiter:
rec.failures += 1
if rec.failures >= self._max:
rec.blocked_until = now + self._cooldown
logger.warning(
"Login blocked for %s after %d failures", ip, rec.failures
)
logger.warning("Login blocked for %s after %d failures", ip, rec.failures)
def record_success(self, ip: str) -> None:
with self._lock:

View File

@@ -22,6 +22,7 @@ class Image(Base):
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(

View File

@@ -27,6 +27,14 @@ class ImageRepository:
)
return result.scalar_one_or_none()
async def get_by_short_id(self, short_id: str) -> Image | None:
result = await self._session.execute(
select(Image)
.where(Image.short_id == short_id)
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def create(
self,
*,
@@ -37,6 +45,7 @@ class ImageRepository:
width: int,
height: int,
storage_key: str,
short_id: str,
thumbnail_key: str | None = None,
) -> Image:
image = Image(
@@ -47,6 +56,7 @@ class ImageRepository:
width=width,
height=height,
storage_key=storage_key,
short_id=short_id,
thumbnail_key=thumbnail_key,
)
self._session.add(image)

View File

@@ -48,9 +48,7 @@ class TagRepository:
for name in tag_names:
tag = await self.upsert_by_name(name)
existing = await self._session.execute(
select(ImageTag).where(
ImageTag.image_id == image.id, ImageTag.tag_id == tag.id
)
select(ImageTag).where(ImageTag.image_id == image.id, ImageTag.tag_id == tag.id)
)
if existing.scalar_one_or_none() is None:
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
@@ -88,7 +86,7 @@ class TagRepository:
query = select(Tag, count_subq.label("image_count"))
if prefix:
query = query.where(Tag.name.like(f"{prefix}%"))
query = query.where(Tag.name.ilike(f"%{prefix}%"))
if min_count > 0:
query = query.where(count_subq >= min_count)
@@ -102,7 +100,6 @@ class TagRepository:
rows = await self._session.execute(paginated)
items = [
{"id": str(tag.id), "name": tag.name, "image_count": count}
for tag, count in rows.all()
{"id": str(tag.id), "name": tag.name, "image_count": count} for tag, count in rows.all()
]
return items, total

View File

@@ -1,7 +1,7 @@
import asyncio
import logging
import re
import struct
import uuid
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
@@ -15,7 +15,7 @@ from app.repositories.image_repo import ImageRepository
from app.repositories.tag_repo import TagRepository
from app.storage.backend import StorageBackend
from app.thumbnail import generate_thumbnail
from app.utils import compute_sha256
from app.utils import compute_sha256, generate_short_id
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
logger = logging.getLogger(__name__)
@@ -23,22 +23,35 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["images"])
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
def _error(detail: str, code: str, status: int):
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
def _validate_short_id(short_id: str) -> str:
if not _SHORT_ID_RE.match(short_id):
raise HTTPException(
status_code=422,
detail={"detail": "Invalid image ID", "code": "invalid_short_id"},
)
return short_id
def _image_to_dict(
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
) -> dict[str, Any]:
_base = cdn_base.strip().rstrip("/") if cdn_base else None
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/images/{image.id}/file"
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/i/{image.short_id}/file"
thumbnail_url = (
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/images/{image.id}/thumbnail")
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/i/{image.short_id}/thumbnail")
if image.thumbnail_key
else None
)
data: dict[str, Any] = {
"id": str(image.id),
"short_id": image.short_id,
"hash": image.hash,
"filename": image.filename,
"mime_type": image.mime_type,
@@ -169,18 +182,24 @@ async def upload_image(
)
width, height = _read_image_dimensions(data, mime_type)
await storage.put(hash_hex, data, mime_type)
from sqlalchemy.exc import IntegrityError
for _ in range(10):
short_id = generate_short_id()
await storage.put(short_id, data, mime_type)
thumbnail_key: str | None = None
try:
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)
await storage.put(f"{hash_hex}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{hash_hex}-thumb"
await storage.put(f"{short_id}-thumb", thumb_bytes, "image/webp")
thumbnail_key = f"{short_id}-thumb"
except Exception:
logger.warning(
"Thumbnail generation failed for %s; upload will proceed without thumbnail", hash_hex
"Thumbnail generation failed for %s; proceeding without thumbnail", short_id
)
try:
image = await image_repo.create(
hash_hex=hash_hex,
filename=file.filename or "upload",
@@ -188,9 +207,23 @@ async def upload_image(
size_bytes=len(data),
width=width,
height=height,
storage_key=hash_hex,
storage_key=short_id,
short_id=short_id,
thumbnail_key=thumbnail_key,
)
break
except IntegrityError:
await db.rollback()
await storage.delete(short_id)
if thumbnail_key:
await storage.delete(thumbnail_key)
thumbnail_key = None
continue
else:
raise HTTPException(
status_code=500,
detail={"detail": "Failed to assign unique ID", "code": "id_collision"},
)
if tag_names:
tag_repo = TagRepository(db)
@@ -221,15 +254,16 @@ async def list_images(
}
@router.get("/images/{image_id}")
@router.get("/i/{short_id}")
async def get_image(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
settings=Depends(get_settings),
):
_validate_short_id(short_id)
_cdn_base = settings.s3_public_base_url
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -238,14 +272,15 @@ async def get_image(
return _image_to_dict(image, cdn_base=_cdn_base)
@router.get("/images/{image_id}/file")
@router.get("/i/{short_id}/file")
async def serve_image_file(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -268,14 +303,15 @@ async def serve_image_file(
)
@router.get("/images/{image_id}/thumbnail")
@router.get("/i/{short_id}/thumbnail")
async def serve_image_thumbnail(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -300,17 +336,18 @@ async def serve_image_thumbnail(
)
@router.patch("/images/{image_id}/tags")
@router.patch("/i/{short_id}/tags")
async def update_image_tags(
image_id: uuid.UUID,
short_id: str,
body: dict,
db: AsyncSession = Depends(get_db),
_: Identity = Depends(require_auth),
settings=Depends(get_settings),
):
_validate_short_id(short_id)
_cdn_base = settings.s3_public_base_url
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,
@@ -332,15 +369,16 @@ async def update_image_tags(
return _image_to_dict(image, cdn_base=_cdn_base)
@router.delete("/images/{image_id}", status_code=204)
@router.delete("/i/{short_id}", status_code=204)
async def delete_image(
image_id: uuid.UUID,
short_id: str,
db: AsyncSession = Depends(get_db),
storage: StorageBackend = Depends(get_storage),
_: Identity = Depends(require_auth),
):
_validate_short_id(short_id)
image_repo = ImageRepository(db)
image = await image_repo.get_by_id(image_id)
image = await image_repo.get_by_short_id(short_id)
if not image:
raise HTTPException(
status_code=404,

View File

@@ -1,5 +1,13 @@
import hashlib
import secrets
import string
BASE62 = string.ascii_letters + string.digits
def compute_sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def generate_short_id(length: int = 8) -> str:
return "".join(secrets.choice(BASE62) for _ in range(length))

0
api/scripts/__init__.py Normal file
View File

View File

@@ -0,0 +1,107 @@
"""
Migrate existing images to use short_id-based storage keys.
Run after applying Alembic migration 003 (adds short_id column).
Run before applying migration 004 (sets short_id NOT NULL).
Usage:
python -m scripts.migrate_to_short_ids
"""
import asyncio
import logging
from typing import Any
from sqlalchemy import select
from app.database import get_session_factory
from app.models import Image
from app.storage.s3_backend import S3StorageBackend
from app.utils import generate_short_id
logger = logging.getLogger(__name__)
async def migrate_image(image: Any, storage: Any, session: Any) -> bool:
"""Migrate one image to a short_id-based key. Returns True if migrated, False if skipped."""
if image.short_id is not None:
return False
new_short_id = generate_short_id()
old_key = image.storage_key
old_thumb_key = image.thumbnail_key
try:
data = await storage.get(old_key)
await storage.put(new_short_id, data, image.mime_type)
# Verify copy succeeded
await storage.get(new_short_id)
except Exception as exc:
logger.error("Failed to copy storage object for image %s: %s", image.id, exc)
return False
new_thumb_key: str | None = None
if old_thumb_key:
try:
thumb_data = await storage.get(old_thumb_key)
new_thumb_key = f"{new_short_id}-thumb"
await storage.put(new_thumb_key, thumb_data, "image/webp")
await storage.get(new_thumb_key)
except Exception as exc:
logger.warning("Failed to copy thumbnail for image %s: %s", image.id, exc)
new_thumb_key = None
try:
image.short_id = new_short_id
image.storage_key = new_short_id
image.thumbnail_key = new_thumb_key
await session.flush()
await storage.delete(old_key)
if old_thumb_key and new_thumb_key:
await storage.delete(old_thumb_key)
except Exception as exc:
logger.error("Failed to update DB record for image %s: %s", image.id, exc)
return False
return True
async def run_migration(images: list, storage: Any, session: Any) -> tuple[int, int, int]:
"""Process a list of images. Returns (migrated, skipped, failed) counts."""
migrated = skipped = failed = 0
for image in images:
if image.short_id is not None:
skipped += 1
continue
try:
success = await migrate_image(image, storage, session)
if success:
migrated += 1
else:
failed += 1
except Exception as exc:
logger.error("Unexpected error migrating image %s: %s", image.id, exc)
failed += 1
return migrated, skipped, failed
async def main() -> None:
logging.basicConfig(level=logging.INFO)
storage = S3StorageBackend()
async with get_session_factory()() as session:
result = await session.execute(select(Image).where(Image.short_id.is_(None)))
images = list(result.scalars().all())
logger.info("Found %d images to migrate", len(images))
migrated, skipped, failed = await run_migration(images, storage, session)
await session.commit()
print(f"Migrated: {migrated}, Skipped: {skipped}, Failed: {failed}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,10 +1,9 @@
"""
T065 — DELETE /api/v1/images/{id} → 204; subsequent GET returns 404
T065 — DELETE /api/v1/i/{short_id} → 204; subsequent GET returns 404
T066 — DELETE verifies MinIO object is removed
T067 — DELETE of unknown ID → 404 image_not_found
"""
import io
import uuid
import pytest
from PIL import Image as PILImage
@@ -28,12 +27,12 @@ async def test_delete_removes_record(authed_client):
files={"file": ("del-test.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
get_resp = await client.get(f"/api/v1/images/{image_id}")
get_resp = await client.get(f"/api/v1/i/{image_id}")
assert get_resp.status_code == 404
assert get_resp.json()["code"] == "image_not_found"
@@ -49,13 +48,13 @@ async def test_delete_removes_storage_object(authed_client):
headers=headers,
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
# Confirm storage redirect no longer works (404 since record is gone)
file_resp = await client.get(f"/api/v1/images/{image_id}/file")
file_resp = await client.get(f"/api/v1/i/{image_id}/file")
assert file_resp.status_code == 404
@@ -63,7 +62,7 @@ async def test_delete_removes_storage_object(authed_client):
async def test_delete_unknown_id_returns_404(authed_client):
client, token = authed_client
response = await client.delete(
f"/api/v1/images/{uuid.uuid4()}",
"/api/v1/i/NotFound",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404
@@ -85,12 +84,12 @@ async def test_delete_removes_thumbnail(authed_client):
headers=headers,
)
assert upload.status_code == 201
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
assert upload.json()["thumbnail_key"] is not None
delete_resp = await client.delete(f"/api/v1/images/{image_id}", headers=headers)
delete_resp = await client.delete(f"/api/v1/i/{image_id}", headers=headers)
assert delete_resp.status_code == 204
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")
thumb_resp = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert thumb_resp.status_code == 404
assert thumb_resp.json()["code"] == "image_not_found"

View File

@@ -3,7 +3,6 @@ Tests that write endpoints require authentication (US2).
These use the authed_client fixture which wires JWTAuthProvider.
"""
import io
import uuid
import pytest
@@ -42,8 +41,7 @@ async def test_upload_with_valid_token_succeeds(authed_client):
@pytest.mark.asyncio
async def test_delete_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/images/{fake_id}")
response = await client.delete("/api/v1/i/NotFound")
assert response.status_code == 401
assert response.json().get("code") == "unauthorized"
@@ -57,9 +55,9 @@ async def test_delete_with_valid_token_succeeds(authed_client):
files={"file": ("del-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.delete(
f"/api/v1/images/{image_id}",
f"/api/v1/i/{image_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 204
@@ -68,9 +66,8 @@ async def test_delete_with_valid_token_succeeds(authed_client):
@pytest.mark.asyncio
async def test_patch_tags_without_token_returns_401(authed_client):
client, _ = authed_client
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/images/{fake_id}/tags",
"/api/v1/i/NotFound/tags",
json={"tags": ["a"]},
)
assert response.status_code == 401
@@ -86,9 +83,9 @@ async def test_patch_tags_with_valid_token_succeeds(authed_client):
files={"file": ("tag-protected.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["protected-tag"]},
headers={"Authorization": f"Bearer {token}"},
)

View File

@@ -30,8 +30,8 @@ async def test_get_image_without_token_is_200(authed_client):
files={"file": ("pub-test.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}")
assert response.status_code == 200
@@ -44,8 +44,8 @@ async def test_serve_file_without_token_is_200(authed_client):
files={"file": ("pub-file.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
@@ -58,8 +58,8 @@ async def test_serve_thumbnail_without_token_is_200(authed_client):
files={"file": ("pub-thumb.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = upload.json()["id"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200

View File

@@ -1,10 +1,9 @@
"""
T055 — GET /api/v1/images/{id}/file → 200 with binary content, ETag, Cache-Control
T055 — GET /api/v1/i/{short_id}/file → 200 with binary content, ETag, Cache-Control
T056 — /file for unknown ID → 404 image_not_found
T057 — /file response exposes no storage-specific details
"""
import io
import uuid
import pytest
from PIL import Image as PILImage
@@ -39,10 +38,10 @@ async def test_file_returns_200_with_content(authed_client):
)
assert upload.status_code in (200, 201)
upload_body = upload.json()
image_id = upload_body["id"]
image_id = upload_body["short_id"]
image_hash = upload_body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/file")
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
assert response.headers["content-type"].startswith("image/")
assert response.headers["etag"] == f'"{image_hash}"'
@@ -52,7 +51,7 @@ async def test_file_returns_200_with_content(authed_client):
@pytest.mark.asyncio
async def test_file_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/file")
response = await client.get("/api/v1/i/NotFound/file")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
@@ -68,9 +67,9 @@ async def test_file_response_exposes_no_storage_details(authed_client):
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code in (200, 201)
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
response = await client.get(f"/api/v1/images/{image_id}/file")
response = await client.get(f"/api/v1/i/{image_id}/file")
assert response.status_code == 200
assert "location" not in response.headers
assert "minio" not in response.text.lower()
@@ -89,10 +88,10 @@ async def test_thumbnail_returns_webp(authed_client):
)
assert upload.status_code == 201
body = upload.json()
image_id = body["id"]
image_id = body["short_id"]
image_hash = body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200
assert response.headers["content-type"] == "image/webp"
assert response.headers["etag"] == f'"{image_hash}"'
@@ -110,15 +109,15 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
headers={"Authorization": f"Bearer {token}"},
)
assert upload.status_code == 201
image_id = upload.json()["id"]
image_id = upload.json()["short_id"]
await db_session.execute(
update(Image).where(Image.id == uuid.UUID(image_id)).values(thumbnail_key=None)
update(Image).where(Image.short_id == image_id).values(thumbnail_key=None)
)
await db_session.flush()
db_session.expire_all()
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
response = await client.get(f"/api/v1/i/{image_id}/thumbnail")
assert response.status_code == 200
assert "image/jpeg" in response.headers["content-type"]
assert len(response.content) > 0
@@ -126,7 +125,7 @@ async def test_thumbnail_fallback_returns_original(authed_client, db_session):
@pytest.mark.asyncio
async def test_thumbnail_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/thumbnail")
response = await client.get("/api/v1/i/NotFound/thumbnail")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"

View File

@@ -81,10 +81,10 @@ async def test_patch_replaces_tag_set(authed_client):
data={"tags": "old-tag"},
headers=headers,
)
image_id = r1.json()["id"]
image_id = r1.json()["short_id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["new-tag", "another"]},
headers=headers,
)
@@ -104,10 +104,10 @@ async def test_patch_invalid_tag_returns_422(authed_client):
files={"file": ("invalid-tag-test.png", io.BytesIO(data), "image/png")},
headers=headers,
)
image_id = r1.json()["id"]
image_id = r1.json()["short_id"]
patch = await client.patch(
f"/api/v1/images/{image_id}/tags",
f"/api/v1/i/{image_id}/tags",
json={"tags": ["valid", "INVALID TAG WITH SPACES!"]},
headers=headers,
)

View File

@@ -3,10 +3,10 @@ T026 — valid JPEG upload → 201, record in DB, object in MinIO
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
T079GET /api/v1/images/{id} 404 → error envelope shape
T013upload produces short_id; storage_key equals short_id; thumbnail_key = {short_id}-thumb
"""
import io
import uuid
import re
from unittest.mock import patch
import pytest
@@ -111,13 +111,81 @@ async def test_upload_oversized_file_returns_422(authed_client):
@pytest.mark.asyncio
async def test_get_unknown_image_returns_404_with_envelope(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
response = await client.get("/api/v1/i/NotFound")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
assert "detail" in body
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
@pytest.mark.asyncio
async def test_upload_returns_short_id(authed_client):
client, token = authed_client
data = _minimal_jpeg()
response = await client.post(
"/api/v1/images",
files={"file": ("s1.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
assert "short_id" in body
assert _SHORT_ID_RE.match(body["short_id"]), f"short_id invalid: {body['short_id']}"
@pytest.mark.asyncio
async def test_upload_storage_key_equals_short_id(authed_client):
client, token = authed_client
data = _real_jpeg(color=(10, 20, 30))
response = await client.post(
"/api/v1/images",
files={"file": ("s2.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
assert body["storage_key"] == body["short_id"]
@pytest.mark.asyncio
async def test_upload_thumbnail_key_equals_short_id_thumb(authed_client):
client, token = authed_client
data = _real_jpeg(color=(30, 60, 90))
response = await client.post(
"/api/v1/images",
files={"file": ("s3.jpg", io.BytesIO(data), "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
body = response.json()
if body["thumbnail_key"] is not None:
assert body["thumbnail_key"] == f"{body['short_id']}-thumb"
@pytest.mark.asyncio
async def test_duplicate_upload_returns_same_short_id(authed_client):
client, token = authed_client
data = _real_jpeg(color=(200, 100, 50))
headers = {"Authorization": f"Bearer {token}"}
r1 = await client.post(
"/api/v1/images",
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r1.status_code in (200, 201)
r2 = await client.post(
"/api/v1/images",
files={"file": ("dup_short.jpg", io.BytesIO(data), "image/jpeg")},
headers=headers,
)
assert r2.status_code == 200
assert r2.json()["duplicate"] is True
assert r2.json()["short_id"] == r1.json()["short_id"]
@pytest.mark.asyncio
async def test_upload_returns_thumbnail_key(authed_client):
client, token = authed_client
@@ -133,9 +201,9 @@ async def test_upload_returns_thumbnail_key(authed_client):
assert body["thumbnail_key"] is not None
assert body["thumbnail_key"].endswith("-thumb")
assert "file_url" in body
assert body["file_url"].startswith("/api/v1/images/")
assert body["file_url"].startswith("/api/v1/i/")
assert "thumbnail_url" in body
assert body["thumbnail_url"].startswith("/api/v1/images/")
assert body["thumbnail_url"].startswith("/api/v1/i/")
@pytest.mark.asyncio
@@ -177,5 +245,5 @@ async def test_upload_succeeds_when_thumbnail_fails(authed_client):
body = response.json()
assert body["thumbnail_key"] is None
assert "file_url" in body
assert body["file_url"].startswith("/api/v1/images/")
assert body["file_url"].startswith("/api/v1/i/")
assert body["thumbnail_url"] is None

View File

@@ -1,5 +1,3 @@
_BASE_ENV = {
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
"S3_ENDPOINT_URL": "http://localhost:9000",
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -43,6 +42,7 @@ def test_settings_max_upload_bytes_override(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -55,6 +55,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -67,6 +68,7 @@ def test_api_docs_enabled_default(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -79,6 +81,7 @@ def test_api_docs_enabled_false(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()
@@ -91,6 +94,7 @@ def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
import importlib
import app.config as config_module
importlib.reload(config_module)
s = config_module.Settings()

View File

@@ -1,6 +1,6 @@
import hashlib
from app.utils import compute_sha256
from app.utils import compute_sha256, generate_short_id
def test_sha256_known_bytes():
@@ -19,3 +19,24 @@ def test_sha256_returns_64_char_hex():
result = compute_sha256(b"test data")
assert len(result) == 64
assert all(c in "0123456789abcdef" for c in result)
def test_generate_short_id_length():
assert len(generate_short_id()) == 8
def test_generate_short_id_charset():
result = generate_short_id()
assert all(
c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for c in result
)
def test_generate_short_id_randomness():
assert generate_short_id() != generate_short_id()
def test_generate_short_id_importable():
from app.utils import generate_short_id as fn
assert callable(fn)

View File

@@ -0,0 +1,110 @@
"""Unit tests for migrate_to_short_ids script logic."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def mock_image_null_short_id():
img = MagicMock()
img.id = "img-uuid-1"
img.short_id = None
img.storage_key = "oldhashkey1234567890"
img.thumbnail_key = "oldhashkey1234567890-thumb"
img.mime_type = "image/jpeg"
return img
@pytest.fixture
def mock_image_with_short_id():
img = MagicMock()
img.id = "img-uuid-2"
img.short_id = "AbCd1234"
img.storage_key = "AbCd1234"
img.thumbnail_key = "AbCd1234-thumb"
img.mime_type = "image/jpeg"
return img
@pytest.mark.asyncio
async def test_migrate_processes_image_without_short_id(mock_image_null_short_id):
"""Images with short_id IS NULL are processed: storage copied, DB updated, old keys deleted."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
storage.get = AsyncMock(return_value=b"imagedata")
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
old_key = mock_image_null_short_id.storage_key
new_short_id = "NewSh123"
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value=new_short_id):
result = await migrate_image(mock_image_null_short_id, storage, session)
assert result is True
storage.put.assert_any_call(new_short_id, b"imagedata", "image/jpeg")
storage.delete.assert_any_call(old_key)
@pytest.mark.asyncio
async def test_migrate_skips_image_with_short_id(mock_image_with_short_id):
"""Images that already have a short_id are skipped."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
session = MagicMock()
result = await migrate_image(mock_image_with_short_id, storage, session)
assert result is False
storage.get.assert_not_called() if hasattr(storage.get, "assert_not_called") else None
@pytest.mark.asyncio
async def test_migrate_continues_on_storage_error(mock_image_null_short_id):
"""If storage copy fails, error is logged and migrate_image returns False without aborting."""
from scripts.migrate_to_short_ids import migrate_image
storage = MagicMock()
storage.get = AsyncMock(side_effect=Exception("storage read error"))
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="ErrSh123"):
result = await migrate_image(mock_image_null_short_id, storage, session)
assert result is False
storage.put.assert_not_called()
@pytest.mark.asyncio
async def test_migrate_summary_counts(mock_image_null_short_id, mock_image_with_short_id):
"""run_migration reports correct migrated and skipped counts."""
from scripts.migrate_to_short_ids import run_migration
storage = MagicMock()
storage.get = AsyncMock(return_value=b"data")
storage.put = AsyncMock()
storage.delete = AsyncMock()
session = MagicMock()
session.execute = AsyncMock()
session.flush = AsyncMock()
images = [mock_image_null_short_id, mock_image_with_short_id]
with patch("scripts.migrate_to_short_ids.generate_short_id", return_value="NewSh999"):
migrated, skipped, failed = await run_migration(images, storage, session)
assert migrated == 1
assert skipped == 1
assert failed == 0

View File

@@ -0,0 +1,59 @@
"""Unit tests for short_id generation, validation, and repository lookup."""
import re
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from app.routers.images import _validate_short_id
from app.utils import generate_short_id
_SHORT_ID_RE = re.compile(r"^[a-zA-Z0-9]{8}$")
def test_validate_short_id_accepts_valid():
_validate_short_id("AbCd1234") # must not raise
def test_validate_short_id_rejects_too_long():
with pytest.raises(HTTPException) as exc:
_validate_short_id("toolong!!")
assert exc.value.status_code == 422
def test_validate_short_id_rejects_too_short():
with pytest.raises(HTTPException) as exc:
_validate_short_id("short")
assert exc.value.status_code == 422
def test_validate_short_id_rejects_invalid_chars():
with pytest.raises(HTTPException) as exc:
_validate_short_id("has spa!")
assert exc.value.status_code == 422
def test_generate_short_id_unique():
ids = {generate_short_id() for _ in range(100)}
assert len(ids) > 90 # collision in 100 draws would be astronomically unlikely
def test_repo_get_by_short_id_uses_correct_field():
"""get_by_short_id selects on Image.short_id, not Image.id."""
import asyncio
from app.repositories.image_repo import ImageRepository
mock_session = MagicMock()
scalar = MagicMock()
scalar.scalar_one_or_none = MagicMock(return_value=None)
mock_session.execute = AsyncMock(return_value=scalar)
repo = ImageRepository(mock_session)
asyncio.get_event_loop().run_until_complete(repo.get_by_short_id("AbCd1234"))
call_args = mock_session.execute.call_args[0][0]
compiled = call_args.compile(compile_kwargs={"literal_binds": True})
assert "short_id" in str(compiled)
assert "AbCd1234" in str(compiled)

View File

@@ -2,17 +2,21 @@
T037 — tag normalisation: uppercase → lowercase, whitespace stripped
T038 — tag validation: rejects names > 64 chars, invalid chars
"""
import pytest
from app.repositories.tag_repo import TagRepository
@pytest.mark.parametrize("raw,expected", [
@pytest.mark.parametrize(
"raw,expected",
[
("Cat", "cat"),
(" funny ", "funny"),
("REACTION", "reaction"),
(" MiXeD ", "mixed"),
])
],
)
def test_normalise_lowercases_and_strips(raw, expected):
assert TagRepository.normalise(raw) == expected

View File

@@ -1,4 +1,5 @@
"""Unit tests for thumbnail generation utility."""
import io
from PIL import Image as PILImage

View File

@@ -1,14 +1,13 @@
import uuid
from unittest.mock import MagicMock
import pytest
from app.routers.images import _image_to_dict
def _make_image(*, thumbnail_key=None):
img = MagicMock()
img.id = uuid.UUID("00000000-0000-0000-0000-000000000001")
img.short_id = "AbCd1234"
img.hash = "abc123"
img.filename = "test.jpg"
img.mime_type = "image/jpeg"
@@ -27,6 +26,7 @@ def test_cdn_configured_with_thumbnail():
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
assert result["short_id"] == "AbCd1234"
def test_cdn_configured_no_thumbnail():
@@ -34,19 +34,20 @@ def test_cdn_configured_no_thumbnail():
result = _image_to_dict(img, cdn_base="https://cdn.example.com")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] is None
assert result["short_id"] == "AbCd1234"
def test_no_cdn_with_thumbnail():
img = _make_image(thumbnail_key="abc123storagekey-thumb")
result = _image_to_dict(img, cdn_base=None)
assert result["file_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/file"
assert result["thumbnail_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/thumbnail"
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
assert result["thumbnail_url"] == "/api/v1/i/AbCd1234/thumbnail"
def test_no_cdn_no_thumbnail():
img = _make_image(thumbnail_key=None)
result = _image_to_dict(img, cdn_base=None)
assert result["file_url"] == "/api/v1/images/00000000-0000-0000-0000-000000000001/file"
assert result["file_url"] == "/api/v1/i/AbCd1234/file"
assert result["thumbnail_url"] is None
@@ -63,3 +64,9 @@ def test_cdn_trailing_whitespace_normalised():
result = _image_to_dict(img, cdn_base="https://cdn.example.com ")
assert result["file_url"] == "https://cdn.example.com/abc123storagekey"
assert result["thumbnail_url"] == "https://cdn.example.com/abc123storagekey-thumb"
def test_short_id_in_response():
img = _make_image()
result = _image_to_dict(img, cdn_base=None)
assert result["short_id"] == "AbCd1234"

View File

@@ -15,7 +15,7 @@ spec:
spec:
initContainers:
- name: migrate
image: git.juggalol.com/juggalol/reactbin-api:v1.3.0
image: git.juggalol.com/juggalol/reactbin-api:v1.4.0
command: ["alembic", "upgrade", "head"]
workingDir: /app
envFrom:
@@ -26,7 +26,7 @@ spec:
runAsUser: 1001
containers:
- name: api
image: git.juggalol.com/juggalol/reactbin-api:v1.3.0
image: git.juggalol.com/juggalol/reactbin-api:v1.4.0
ports:
- containerPort: 8000
envFrom:

View File

@@ -15,7 +15,7 @@ spec:
spec:
containers:
- name: ui
image: git.juggalol.com/juggalol/reactbin-ui:v1.3.0
image: git.juggalol.com/juggalol/reactbin-ui:v1.4.0
ports:
- containerPort: 8080
livenessProbe:

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Short Image IDs
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [X] No implementation details (languages, frameworks, APIs)
- [X] Focused on user value and business needs
- [X] Written for non-technical stakeholders
- [X] All mandatory sections completed
## Requirement Completeness
- [X] No [NEEDS CLARIFICATION] markers remain
- [X] Requirements are testable and unambiguous
- [X] Success criteria are measurable
- [X] Success criteria are technology-agnostic (no implementation details)
- [X] All acceptance scenarios are defined
- [X] Edge cases are identified
- [X] Scope is clearly bounded
- [X] Dependencies and assumptions identified
## Feature Readiness
- [X] All functional requirements have clear acceptance criteria
- [X] User scenarios cover primary flows
- [X] Feature meets measurable outcomes defined in Success Criteria
- [X] No implementation details leak into specification
## Notes
- All items pass. Ready for /speckit-plan.

View File

@@ -0,0 +1,115 @@
# Contract: Image API (Short ID Update)
## ImageRecord Response Schema
All image endpoints return this shape. `short_id` is a new field; all other fields are unchanged.
```json
{
"id": "7343d164-80bb-473b-b239-717f2842ae4e",
"short_id": "xK7mN2pQ",
"hash": "163dec08460650439f1e7439721e8e566aff7d8aaad60cf451e7d3518a334a23",
"filename": "image.gif",
"mime_type": "image/gif",
"size_bytes": 1957149,
"width": 265,
"height": 199,
"storage_key": "xK7mN2pQ",
"thumbnail_key": "xK7mN2pQ-thumb",
"file_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ",
"thumbnail_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ-thumb",
"created_at": "2026-05-09T02:46:29.520296+00:00",
"tags": ["kfc"]
}
```
**Constraints**:
- `short_id`: exactly 8 alphanumeric characters `[a-zA-Z0-9]{8}`
- `storage_key`: equals `short_id` (post-migration)
- `thumbnail_key`: equals `{short_id}-thumb` or `null` if no thumbnail exists
- `file_url`: `{cdn_base}/{short_id}` when CDN is configured; `/api/v1/images/{short_id}/file` otherwise
- `thumbnail_url`: `{cdn_base}/{short_id}-thumb` or `null`
---
## Route Changes
All routes that previously accepted `{image_id}` as a UUID now accept `{short_id}` as an 8-character alphanumeric string.
### GET /api/v1/images/{short_id}
Fetch a single image by short ID.
- **Path param**: `short_id` — 8-char alphanumeric string
- **Response 200**: ImageRecord
- **Response 404**: `{"detail": "Image not found", "code": "image_not_found"}`
- **Response 422**: `{"detail": "Invalid image ID", "code": "invalid_short_id"}` if param is not 8 alphanumeric chars
### PATCH /api/v1/images/{short_id}/tags
Update tags on an image. Auth required.
- **Path param**: `short_id` — 8-char alphanumeric string
- **Body**: `{"tags": ["tag1", "tag2"]}`
- **Response 200**: ImageRecord (updated)
- **Response 404/422**: same shape as above
### DELETE /api/v1/images/{short_id}
Delete an image and its storage objects. Auth required.
- **Path param**: `short_id` — 8-char alphanumeric string
- **Response 204**: no body
- **Response 404**: error envelope
### GET /api/v1/images/{short_id}/file
Serve the raw image file (proxy mode, when CDN is not configured).
- **Path param**: `short_id`
- **Response 200**: raw image bytes with correct `Content-Type`
### GET /api/v1/images/{short_id}/thumbnail
Serve the thumbnail (proxy mode).
- **Path param**: `short_id`
- **Response 200**: WebP bytes or original image if no thumbnail
### POST /api/v1/images (upload — unchanged route, updated response)
- **Response**: ImageRecord with `short_id` populated
---
## Frontend Route Change
| Old route | New route |
|-----------------|--------------|
| `/images/:id` | `/i/:id` |
The `:id` segment now contains the `short_id` value (8 alphanumeric chars) rather than a UUID.
---
## ImageRecord TypeScript Interface (updated)
```typescript
export interface ImageRecord {
id: string; // UUID — retained, not used for routing
short_id: string; // NEW — 8-char base62, used for all routing and API calls
hash: string;
filename: string;
mime_type: string;
size_bytes: number;
width: number;
height: number;
storage_key: string;
thumbnail_key: string | null;
file_url: string;
thumbnail_url: string | null;
created_at: string;
tags: string[];
duplicate?: boolean;
}
```

View File

@@ -0,0 +1,77 @@
# Data Model: Short Image IDs
## Changed Entity: Image
### New Column
| Column | Type | Constraints | Notes |
|------------|--------------|------------------------------|-------------------------------------------|
| `short_id` | VARCHAR(8) | UNIQUE, NOT NULL (post-migration), INDEX | Base62 alphanumeric, 8 characters |
### Updated Columns (values change, types unchanged)
| Column | Old values | New values |
|-----------------|-----------------------------------------|-----------------------------------|
| `storage_key` | SHA-256 hash (64 hex chars) | short_id (8 base62 chars) |
| `thumbnail_key` | `{hash}-thumb` (69 chars) | `{short_id}-thumb` (13 chars) |
### Unchanged Columns
| Column | Notes |
|------------|-----------------------------------------------------------------------|
| `id` | UUID primary key — unchanged, retained as internal identifier |
| `hash` | SHA-256 content hash — unchanged, still used for deduplication |
| `filename` | Unchanged |
| `mime_type`| Unchanged |
| `size_bytes`, `width`, `height` | Unchanged |
| `created_at` | Unchanged |
### Validation Rules
- `short_id`: exactly 8 characters, matching `[a-zA-Z0-9]{8}` — generated on insert, never updated
- `short_id` must be unique across all image records
- On collision (rare), a new value is generated and retried (up to 10 attempts)
---
## Alembic Migrations
### Migration 003 — Add `short_id` column (nullable)
```
ALTER TABLE images ADD COLUMN short_id VARCHAR(8) NULL;
CREATE UNIQUE INDEX ix_images_short_id ON images (short_id);
```
Run immediately on deploy. Existing rows get `short_id = NULL`. New uploads will populate `short_id` on insert (application-level).
### Migration Script — Backfill existing rows
`api/scripts/migrate_to_short_ids.py`
For each image where `short_id IS NULL`:
1. Generate 8-char base62 short_id (retry on collision)
2. Copy storage object: `{hash}``{short_id}` (S3 copy)
3. Copy thumbnail if present: `{hash}-thumb``{short_id}-thumb`
4. Verify new objects exist (S3 head_object)
5. Update DB row: `short_id = {short_id}`, `storage_key = {short_id}`, `thumbnail_key = {short_id}-thumb` (or NULL)
6. Delete old storage objects
### Migration 004 — Add NOT NULL constraint
```
ALTER TABLE images ALTER COLUMN short_id SET NOT NULL;
```
Run only after the migration script completes successfully with zero `short_id IS NULL` rows remaining.
---
## Storage Object Naming Convention
| Object type | Key pattern | Example |
|-------------|---------------------|-------------------|
| Original | `{short_id}` | `xK7mN2pQ` |
| Thumbnail | `{short_id}-thumb` | `xK7mN2pQ-thumb` |
No folder structure. Flat bucket layout (unchanged from current convention).

View File

@@ -0,0 +1,198 @@
# Implementation Plan: Short Image IDs
**Branch**: `017-short-id-migration` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/017-short-id-migration/spec.md`
## Summary
Replace hash-based storage keys and UUID-based URL routing with 8-character base62 short IDs. The short ID becomes the canonical identifier in URLs (`/i/:short_id`), storage keys (`{short_id}` / `{short_id}-thumb`), and all API responses. Hash-based deduplication is preserved unchanged. A Python migration script handles existing images: generates short IDs, copies storage objects to new keys, updates DB records, deletes old keys.
## Technical Context
**Language/Version**: Python 3.12+ (API), TypeScript strict (UI)
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, Alembic, aiobotocore/boto3, Angular (latest stable)
**Storage**: PostgreSQL (DB), S3-compatible via boto3 (MinIO local / CDN in prod)
**Testing**: pytest + pytest-asyncio (API unit + integration), Karma/Jasmine (Angular)
**Target Platform**: Linux server (k3s), browser SPA
**Project Type**: Web application (FastAPI API + Angular SPA)
**Performance Goals**: Migration script should process all existing images without timeout; no user-facing performance change
**Constraints**: Migration must be idempotent; no data loss; copy-before-delete for all storage operations
**Scale/Scope**: Personal collection (~hundreds to low thousands of images); collision probability negligible
## Constitution Check
*GATE: Must pass before implementation.*
| Principle | Status | Notes |
|-----------|--------|-------|
| §2.5 DB abstraction — all queries through repos | ✅ PASS | New `get_by_short_id()` added to `ImageRepository`; no raw SQL outside repo |
| §2.6 No speculative abstraction | ✅ PASS | `generate_short_id()` is a concrete utility; no new interfaces |
| §3.1 Routes prefixed `/api/v1/` | ✅ PASS | All routes remain under `/api/v1/images/` |
| §3.1 Adding fields is non-breaking | ✅ PASS | `short_id` is additive; `id` UUID retained |
| §4.2 Images immutable after upload | ✅ PASS | File content is copied, not replaced; the operation changes the storage key, not the bytes |
| §4.3 Deduplication by content hash | ✅ PASS | `hash` column retained; `get_by_hash` unchanged |
| §5.1 Tests alongside every implementation task | ✅ PASS | Each task includes tests |
| §5.2 Integration tests use real PostgreSQL + MinIO | ✅ PASS | Existing integration test infrastructure reused |
| §8 Scope boundaries | ✅ PASS | No multi-user, no public sharing feature, no OR/NOT tag logic |
**No violations. Implementation may proceed.**
## Project Structure
### Documentation (this feature)
```text
specs/017-short-id-migration/
├── plan.md ← this file
├── research.md ← short ID generation, migration strategy
├── data-model.md ← Image schema changes, Alembic migrations
├── contracts/
│ └── image-api.md ← updated ImageRecord schema, route changes
├── quickstart.md ← manual test scenarios
└── tasks.md ← generated by /speckit-tasks
```
### Source Code Changes
```text
api/
├── app/
│ ├── models.py # Add Image.short_id column
│ ├── utils.py # Add generate_short_id()
│ ├── repositories/
│ │ └── image_repo.py # Add get_by_short_id(), update create()
│ └── routers/
│ └── images.py # Path params uuid→str, add short_id to response
├── alembic/versions/
│ ├── 003_add_short_id.py # ADD COLUMN short_id VARCHAR(8) NULLABLE UNIQUE
│ └── 004_short_id_not_null.py # SET NOT NULL (run after migration script)
├── scripts/
│ └── migrate_to_short_ids.py # Backfill existing images
└── tests/
├── unit/
│ ├── test_hashing.py # Add generate_short_id() tests
│ ├── test_url_construction.py # Update mock images to include short_id
│ └── test_short_id.py # NEW: collision retry, charset validation
└── integration/
├── test_upload.py # Assert short_id in response
├── test_search.py # Update {id} → {short_id} in route calls
├── test_delete.py # Update route params
├── test_serving.py # Update route params
└── test_tags.py # Update route params
ui/src/app/
├── app.routes.ts # 'images/:id' → 'i/:id'
├── services/
│ └── image.service.ts # Add short_id to ImageRecord, update service calls
├── library/
│ └── library.component.ts # Navigate to ['/i', img.short_id]
├── upload/
│ └── upload.component.ts # Navigate to ['/i', res.short_id] after upload
└── detail/
└── detail.component.ts # (no route change needed; reads :id param same way)
```
**Structure Decision**: Existing web application layout. API changes are concentrated in models, repository, router, and a new migration script. UI changes are confined to routes, image service interface, and two navigation calls.
## Implementation Phases
### Phase 1: Backend — Short ID Infrastructure
1. Add `generate_short_id()` to `api/app/utils.py`
- Base62 charset: `string.ascii_letters + string.digits`
- Uses `secrets.choice` for cryptographic randomness
- Returns 8-character string
2. Add Alembic migration `003_add_short_id.py`
- `ADD COLUMN short_id VARCHAR(8) NULL`
- `CREATE UNIQUE INDEX ix_images_short_id ON images (short_id)`
3. Update `api/app/models.py`
- Add `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`
4. Update `api/app/repositories/image_repo.py`
- Add `get_by_short_id(short_id: str) -> Image | None`
- Update `create()` to accept and persist `short_id` parameter
5. Update `api/app/routers/images.py`
- Change all `image_id: uuid.UUID` path params to `short_id: str`
- Add `_validate_short_id(short_id: str)` helper (8 alphanumeric chars, else 422)
- Replace `get_by_id` calls with `get_by_short_id`
- Update `_image_to_dict` to include `"short_id": image.short_id` in response
- Update upload handler: generate `short_id` with collision retry, use as storage key
### Phase 2: Migration Script
`api/scripts/migrate_to_short_ids.py`:
```
for each image where short_id IS NULL:
generate short_id (retry on DB collision)
copy {hash} → {short_id} in storage
if thumbnail_key IS NOT NULL:
copy {hash}-thumb → {short_id}-thumb in storage
verify new objects exist (head_object)
UPDATE images SET short_id={sid}, storage_key={sid}, thumbnail_key={sid}-thumb WHERE id={id}
delete {hash} from storage
if thumbnail_key was not null:
delete {hash}-thumb from storage
log: "migrated {id} → {short_id}"
print summary: N migrated, M skipped (already had short_id)
```
After script runs with 0 remaining `NULL` short_ids, apply migration `004_short_id_not_null.py`.
### Phase 3: Frontend
1. `app.routes.ts`: `path: 'images/:id'``path: 'i/:id'`
2. `image.service.ts`: add `short_id: string` to `ImageRecord`
3. `library.component.ts`: `router.navigate(['/images', img.id])``router.navigate(['/i', img.short_id])`
4. `upload.component.ts`: `router.navigate(['/images', res.id])``router.navigate(['/i', res.short_id])`
### Phase 4: Polish
- Update all existing API integration tests to use `short_id` in route paths
- Run `ng lint` and `ruff check` across modified files
- Verify `ng build --configuration production` succeeds
- Run full test suites: `make test-unit && make test-integration`
## Key Implementation Notes
### Collision Retry Pattern (upload)
```python
MAX_RETRIES = 10
for attempt in range(MAX_RETRIES):
short_id = generate_short_id()
try:
image = await image_repo.create(..., short_id=short_id)
break
except IntegrityError: # short_id collision
await db.rollback()
if attempt == MAX_RETRIES - 1:
raise RuntimeError("Could not generate unique short_id")
```
### Route Validation
```python
import re
_SHORT_ID_RE = re.compile(r'^[a-zA-Z0-9]{8}$')
def _validate_short_id(short_id: str) -> None:
if not _SHORT_ID_RE.match(short_id):
raise HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})
```
### `_image_to_dict` Update
Add `"short_id": image.short_id` to the returned dict. The `file_url` and `thumbnail_url` generation already uses `image.storage_key` which will now equal `image.short_id` — no formula change needed.
### Migration Script Entry Point
```bash
cd api && python -m scripts.migrate_to_short_ids
```
Reads DB URL and storage config from environment variables (same as the application).

View File

@@ -0,0 +1,73 @@
# Quickstart: Short Image IDs
## Scenario 1 — Happy Path: New Upload Gets Short ID
1. Log in and navigate to Upload.
2. Upload any image.
3. Observe: browser navigates to `/i/AbCdEfGh` (8-char short ID, not a UUID).
4. Copy the URL from the address bar and paste in a new tab — image loads correctly.
5. Open the URL in a private/incognito window (not logged in) — image still loads.
**Pass criteria**: URL is `/i/{8 alphanumeric chars}`, image loads authenticated and unauthenticated.
---
## Scenario 2 — Deduplication Still Works
1. Upload any image — note the short ID in the URL.
2. Upload the exact same file again.
3. Observe: API returns `duplicate: true`, browser navigates to the same short ID URL as step 1.
**Pass criteria**: No second record created, same short ID returned.
---
## Scenario 3 — Library Navigation Uses Short IDs
1. Open the library (`/`).
2. Click any image card.
3. Observe: navigated to `/i/{short_id}`, not `/images/{uuid}`.
**Pass criteria**: All image card clicks navigate to `/i/` routes.
---
## Scenario 4 — Tag and Delete Operations Work via Short ID
1. Open an image detail page at `/i/{short_id}`.
2. If logged in: add a tag, remove a tag — confirm both succeed.
3. If logged in: delete the image — confirm navigates back to library, image no longer appears.
**Pass criteria**: Tag updates and delete work correctly when the route uses a short ID.
---
## Scenario 5 — Migration: All Existing Images Accessible
1. After running the migration script: open the library.
2. Click through several images from before the migration.
3. Observe: all navigate to `/i/{short_id}` URLs, all images and thumbnails load.
4. No broken image placeholders visible.
**Pass criteria**: 100% of pre-migration images accessible via short ID with no broken assets.
---
## Scenario 6 — Migration Script Is Idempotent
1. Run the migration script once — note how many images were migrated.
2. Run the migration script a second time.
3. Observe: script reports 0 images migrated (all already have short IDs), exits cleanly.
**Pass criteria**: Second run produces no DB changes, no storage operations, no errors.
---
## Scenario 7 — Copy URL Button Copies Short Page URL
1. Open any image detail page at `/i/{short_id}`.
2. Click "Copy URL".
3. Paste into a text editor.
4. Observe: pasted value is the CDN file URL (e.g. `https://cdn.reactbin.juggalol.com/xK7mN2pQ`), not a UUID-based URL.
**Pass criteria**: Copied URL contains the short_id, not a UUID.

View File

@@ -0,0 +1,56 @@
# Research: Short Image IDs
## Short ID Generation
**Decision**: Use `secrets.choice` over `string.ascii_letters + string.digits` (base62, 62 characters), 8 characters long.
**Rationale**: `secrets.choice` is cryptographically random, eliminating any bias from modular reduction that affects simpler approaches. Base62 (az, AZ, 09) is URL-safe without percent-encoding. 8 characters gives 62⁸ ≈ 218 trillion combinations — negligible collision probability even at millions of images.
**Alternatives considered**:
- `secrets.token_urlsafe(6)` — includes `-` and `_`, not pure alphanumeric
- UUID truncation (first 8 chars of hex) — only 16 chars of alphabet (hex), dramatically fewer combinations than base62
- nanoid (npm) — JavaScript library, requires a separate dependency for Python
**Collision retry**: On insert, if a `UniqueConstraint` violation is raised on `short_id`, generate a new one and retry (up to a configurable limit, e.g., 10 attempts). At 10,000 images the per-attempt collision probability is ~4.6 × 10⁻¹¹; retries are a pure safety measure.
---
## Alembic Two-Phase Migration Strategy
**Decision**: Two separate Alembic migrations (003 + 004), with the Python migration script run between them.
**Rationale**: The `short_id` column must start nullable so existing rows can be inserted without a value. The migration script fills all existing rows. Once confirmed, a second migration adds the NOT NULL constraint. Running both as one migration would require a complex inline Python script in Alembic (fragile, untestable). Two migrations with a script in between is the standard approach for backfill + constraint change.
**Migration 003**: `ADD COLUMN short_id VARCHAR(8) NULL UNIQUE` + GiST/B-tree index.
**Script**: Fill all rows, idempotent (skip rows where `short_id IS NOT NULL`).
**Migration 004**: `ALTER COLUMN short_id SET NOT NULL`.
---
## Storage Object Copy Strategy
**Decision**: Copy-then-verify-then-delete (not atomic rename). Using the MinIO/S3 `copy_object` API followed by a `delete_object` call.
**Rationale**: S3-compatible object stores do not support atomic renames. The safe approach is: copy to new key, verify new object exists (head_object), update DB, delete old key. If interrupted after copy but before delete, the old object remains — wasted storage but no data loss. The migration is idempotent: if `short_id` is already set on a row, the script skips it.
**Alternatives considered**:
- `mc mv` (MinIO client CLI) — simpler but harder to script transactionally with DB updates
- Direct Python with `aiobotocore` — chosen; same library already used by the storage backend
---
## API Route Parameter Change
**Decision**: Change all image route parameters from `image_id: uuid.UUID` to `short_id: str` with manual length/charset validation.
**Rationale**: FastAPI's `uuid.UUID` type annotation rejects non-UUID strings at the path-parsing stage, so the existing routes cannot accept short IDs without a type change. Switching to `str` with a custom validator (8 alphanumeric chars) is minimal and clear.
**Impact**: All routes under `/api/v1/images/{id}` change to accept an 8-char string. The `id` field in API responses is retained as the UUID; `short_id` is added as a new field. The UI switches to using `short_id` for all navigation and API calls.
---
## Response Schema: Additive Change
**Decision**: Add `short_id` as a new field to the image response dict. The existing `id` (UUID) field is retained.
**Rationale**: Adding a field is non-breaking per §3.1. Removing `id` would be a breaking change. Retaining both allows any internal tooling or API consumers that already use `id` to continue working. The UI transitions to using `short_id` for routing and API calls, but the UUID remains queryable if needed.

View File

@@ -0,0 +1,104 @@
# Feature Specification: Short Image IDs
**Feature Branch**: `017-short-id-migration`
**Created**: 2026-05-09
**Status**: Draft
**Input**: User description: "Replace UUID-based image identifiers with 8-character base62 short IDs. Short IDs become the canonical identifier in URLs (/i/:short_id replacing /images/:uuid), MinIO storage keys, and all API responses. Existing hash-based deduplication is preserved. Migration includes backfilling short IDs for existing images, renaming storage objects, and regenerating file URLs. Frontend routes update to use short IDs throughout."
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Clean, Shareable Image Links (Priority: P1)
A user wants to share an image with someone. They copy the page URL or use the "Copy URL" button and get a short, clean link they can paste anywhere. The link is brief enough to share in a message without looking like machine-generated noise.
**Why this priority**: This is the primary user-facing value of the change. Every image in the library benefits immediately. Short links are more trustworthy, easier to share, and less likely to break in messaging apps that truncate long URLs.
**Independent Test**: Open any image detail page. Confirm the URL in the browser address bar is short (e.g. `/i/AbCdEfGh`). Copy the URL and paste it into a new tab — confirm the correct image loads. Share the link with someone who is not logged in and confirm they can view the image.
**Acceptance Scenarios**:
1. **Given** a user is on the image detail page, **When** they look at the browser address bar, **Then** the URL contains a short 8-character identifier rather than a long UUID.
2. **Given** a short image URL, **When** an unauthenticated user opens it, **Then** the image loads correctly without requiring login.
3. **Given** a short image URL, **When** it is pasted into a messaging app or email, **Then** it is compact enough to read at a glance and does not get truncated.
---
### User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
When a new image is uploaded, the system assigns it a short ID immediately. The image is accessible via its short URL straight away. If the same file has already been uploaded before, the existing record is returned rather than creating a duplicate — the deduplication behaviour is unchanged.
**Why this priority**: This ensures the new convention is in place going forward. Without this, the migration work in US3 would need to be re-run for any new uploads.
**Independent Test**: Upload a new image. Confirm the detail page URL contains an 8-character short ID. Upload the exact same file again — confirm no new record is created and the existing short URL is returned.
**Acceptance Scenarios**:
1. **Given** a user uploads an image, **When** the upload completes, **Then** the image is accessible at a short URL (`/i/{short_id}`).
2. **Given** a user uploads a file that is identical to a previously uploaded image, **When** the upload completes, **Then** the system returns the existing image's short URL rather than creating a duplicate entry.
3. **Given** a newly uploaded image, **When** the "Copy URL" button is used, **Then** the copied link is the short image page URL.
---
### User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
All images that existed before this change are assigned short IDs and remain fully accessible. Their stored files are renamed to match the new convention. After migration, all image links throughout the application use short IDs — no UUID-based links remain active.
**Why this priority**: Without migration, legacy images would either be inaccessible or require maintaining two parallel URL schemes. Clean cutover is preferable. This is lower priority than P1/P2 because it is an administrative operation rather than a user-facing feature, but it must complete before the feature can be considered fully shipped.
**Independent Test**: After running the migration, browse the library and open several images — confirm all detail pages use short URLs. Confirm no broken images or missing thumbnails.
**Acceptance Scenarios**:
1. **Given** images that existed before the migration, **When** the migration completes, **Then** all are accessible via short URLs.
2. **Given** the migration has run, **When** a user browses the library and opens any image, **Then** the detail page URL is a short ID URL.
3. **Given** the migration has run, **When** any image or thumbnail is displayed, **Then** it loads correctly with no broken images.
4. **Given** the migration is running, **When** it encounters an error on one image, **Then** it reports the failure clearly and continues processing remaining images rather than aborting entirely.
---
### Edge Cases
- What happens if a short ID collision occurs during generation? The system must retry with a new ID rather than failing or overwriting an existing image.
- What happens if a record lacks a short ID but the file content is unchanged? The migration assigns a new short ID without re-uploading the file.
- What happens if the migration is interrupted partway through? Already-migrated images remain accessible; un-migrated images are identifiable so the migration can be re-run safely.
- What happens if a thumbnail does not exist for an image (e.g., GIFs where generation failed)? The migration skips the thumbnail rename for that record and continues.
- What happens if a user has bookmarked a UUID-based URL before the migration? Those URLs become invalid; this is acceptable for a personal tool with no external consumers.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST assign every image a unique 8-character short ID composed of alphanumeric characters (az, AZ, 09).
- **FR-002**: Every image detail page MUST be accessible at the path `/i/{short_id}`.
- **FR-003**: The UUID-based image detail route (`/images/{uuid}`) MUST be retired; short ID routes are the sole canonical paths.
- **FR-004**: Image storage objects (original and thumbnail) MUST use the short ID as their storage key, following flat naming: `{short_id}` for the original and `{short_id}-thumb` for the thumbnail.
- **FR-005**: The publicly accessible image file URL and thumbnail URL MUST reflect the new storage key names.
- **FR-006**: On upload, the system MUST check whether an identical file (by hash) already exists and return the existing record rather than creating a duplicate, regardless of short IDs.
- **FR-007**: The system MUST generate a new short ID on upload, retrying automatically if a collision with an existing ID is detected.
- **FR-008**: A migration process MUST assign short IDs to all existing images that do not have one, rename their storage objects to match the new keys, and update all stored URLs.
- **FR-009**: The migration MUST be re-runnable safely — images already migrated MUST be skipped rather than processed again.
- **FR-010**: All application links that reference images (library grid, detail page, API responses) MUST use short IDs after the migration.
### Key Entities
- **Image**: Each image has a unique short ID (8 alphanumeric characters) that serves as its canonical identifier in URLs, storage, and API responses. The image retains its content hash for deduplication. The short ID is independent of the hash.
- **Storage Object**: Each image has two storage objects — an original and a thumbnail — named using the short ID (`{short_id}` and `{short_id}-thumb`). Flat naming, no folder structure.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All image detail page URLs use an 8-character alphanumeric identifier at `/i/{short_id}`.
- **SC-002**: 100% of existing images are accessible via short URL after migration completes, with no broken images or missing thumbnails.
- **SC-003**: Uploading the same file twice produces one record — deduplication rate remains 100% for identical files.
- **SC-004**: The migration completes without data loss — no image file or thumbnail is deleted before its renamed copy is confirmed present in storage.
- **SC-005**: The migration is idempotent — running it a second time produces no changes and no errors.
## Assumptions
- UUID-based image URLs do not need to remain accessible after migration; this is a personal tool with no external consumers relying on the old URL structure.
- The migration will be run manually by the operator as a one-time administrative step; it does not need to be triggered from the UI.
- Storage object renaming is implemented as copy-then-delete to avoid data loss if the process is interrupted mid-run.
- The short ID character set is base62 (az, AZ, 09); no special characters, ensuring URL-safe identifiers without percent-encoding.
- The `hash` column is retained and continues to be used for deduplication; it is not removed as part of this change.
- Thumbnails may not exist for all images (e.g., some GIFs); the migration handles missing thumbnails gracefully by skipping the thumbnail rename for those records.

View File

@@ -0,0 +1,162 @@
# Tasks: Short Image IDs
**Input**: Design documents from `specs/017-short-id-migration/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/image-api.md ✅, quickstart.md ✅
**Tests**: Tests accompany each implementation task per §5.1. All API changes are in `api/`, all UI changes are in `ui/src/app/`.
**Organization**: The foundational phase (Phase 1) must complete before any user story work begins — it adds the `short_id` column, model field, utility function, and repository method that all three user stories depend on. US1 and US2 can then proceed; US3 (migration script) follows last because it operates on the fully wired system.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to
---
## Phase 1: Foundational — Short ID Infrastructure (Blocks All User Stories)
**Goal**: Add the `short_id` column to the database, the model, a generation utility, and a repository lookup. Every user story depends on these.
**Independent Test**: After this phase, `generate_short_id()` can be called from a Python shell and returns an 8-character alphanumeric string. `alembic upgrade head` applies migration 003 cleanly. A manually inserted image with a `short_id` can be fetched by `image_repo.get_by_short_id()` in a test.
- [X] T001 Write failing unit tests for `generate_short_id()` in `api/tests/unit/test_hashing.py`: (1) returns exactly 8 characters; (2) contains only `[a-zA-Z0-9]` characters; (3) two consecutive calls return different values (collision test); (4) function exists and is importable from `app.utils`. Run `make test-unit` and confirm new tests FAIL.
- [X] T002 Add `generate_short_id()` to `api/app/utils.py`: import `secrets` and `string`; define `BASE62 = string.ascii_letters + string.digits`; implement `def generate_short_id(length: int = 8) -> str: return ''.join(secrets.choice(BASE62) for _ in range(length))`. Run `make test-unit` and confirm T001 tests pass.
- [X] T003 Create Alembic migration `api/alembic/versions/003_add_short_id.py`: `op.add_column('images', sa.Column('short_id', sa.String(8), nullable=True))`; `op.create_index('ix_images_short_id', 'images', ['short_id'], unique=True)`. Downgrade removes index then column. Run `alembic upgrade head` in the api container and confirm migration applies cleanly.
- [X] T004 [P] Add `short_id` field to `Image` model in `api/app/models.py`: `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`. No change to column sizes for `storage_key` (keep String(64)) or `thumbnail_key` (keep String(70)) — values will simply be shorter after migration.
- [X] T005 Update `api/app/repositories/image_repo.py`: (a) add `async def get_by_short_id(self, short_id: str) -> Image | None` — SELECT with `Image.short_id == short_id` and `selectinload(Image.image_tags).selectinload(ImageTag.tag)`; (b) add `short_id: str` parameter to `create()` and persist it on the `Image` instance. Write a unit test in `api/tests/unit/` mocking the session to confirm `get_by_short_id` constructs the correct WHERE clause.
- [X] T005a Update `api/tests/integration/conftest.py`: wherever test fixtures call `image_repo.create()` or insert image rows directly, add `short_id=generate_short_id()` (import `generate_short_id` from `app.utils`). This ensures all integration test fixture images have a `short_id` value so that tests referencing `image.short_id` in URLs and assertions work correctly. Run `make test-integration` and confirm existing tests still pass (no new failures introduced).
**Checkpoint**: Short ID infrastructure complete. The `short_id` column exists in DB, `generate_short_id()` works, and the repo can look up images by short_id.
---
## Phase 2: User Story 1 — Clean, Shareable Image Links (Priority: P1) 🎯 MVP
**Goal**: All API routes accept `short_id` (8-char string) instead of UUID. `short_id` appears in every API response. The frontend navigates to `/i/:short_id` and the library uses `short_id` for navigation.
**Independent Test**: Upload a new image (US2 must be done for end-to-end, but US1 can be tested using a fixture image with a known `short_id` inserted directly). Call `GET /api/v1/images/{short_id}` and confirm it returns the correct image with `short_id` in the response. Navigate to `/i/{short_id}` in the browser and confirm the detail page loads.
- [X] T006 Write failing unit tests in `api/tests/unit/test_url_construction.py`: update `_make_image()` mock to include `short_id = 'AbCd1234'`; add assertions that `_image_to_dict` result includes `"short_id": "AbCd1234"`. Write failing unit tests in `api/tests/unit/test_short_id.py`: (1) `_validate_short_id('AbCd1234')` passes; (2) `_validate_short_id('toolong!!')` raises 422; (3) `_validate_short_id('short')` raises 422; (4) `_validate_short_id('has space!')` raises 422. Run `make test-unit` and confirm new tests FAIL.
- [X] T007 Update `api/app/routers/images.py``_image_to_dict`: add `"short_id": image.short_id` to the returned dict (between `"id"` and `"hash"`). Add `_validate_short_id(short_id: str) -> None` helper: compile `re.compile(r'^[a-zA-Z0-9]{8}$')` at module level; raise `HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})` if no match. Run `make test-unit` and confirm T006 tests pass.
- [X] T008 Update all image route handlers in `api/app/routers/images.py` — change every `image_id: uuid.UUID` path parameter to `short_id: str`; call `_validate_short_id(short_id)` at the start of each handler; replace all `image_repo.get_by_id(image_id)` calls with `image_repo.get_by_short_id(short_id)`. Affected routes: `GET /images/{short_id}`, `GET /images/{short_id}/file`, `GET /images/{short_id}/thumbnail`, `PATCH /images/{short_id}/tags`, `DELETE /images/{short_id}`. Remove `import uuid` if no longer used.
- [X] T009 [P] Write failing Angular tests: (a) in `ui/src/app/services/image.service.ts` — update `MOCK_IMAGE` in `detail.component.spec.ts` and any other spec files to include `short_id: 'AbCd1234'`; (b) in `ui/src/app/library/library.component.spec.ts` — add test asserting that clicking an image card calls `router.navigate` with `['/i', img.short_id]` rather than `['/images', img.id]`. Run `ng test --watch=false` and confirm new tests FAIL.
- [X] T010 Update `ui/src/app/app.routes.ts`: change `path: 'images/:id'` to `path: 'i/:id'`. The `DetailComponent` reads `this.route.snapshot.paramMap.get('id')` — no change needed there since the param name `:id` is unchanged.
- [X] T011 Add `short_id: string` to the `ImageRecord` interface in `ui/src/app/services/image.service.ts`. No changes to method signatures — `get(id)`, `updateTags(id, ...)`, and `delete(id)` already accept `string`; callers will now pass `short_id` values instead of UUIDs.
- [X] T011a Update `ui/src/app/detail/detail.component.ts`: change `this.imageService.updateTags(this.image.id, updated)` (×2, lines ~214 and ~224) and `this.imageService.delete(this.image.id)` (line ~235) to use `this.image.short_id` instead of `this.image.id`. After T008 the API accepts only 8-char short_ids; passing a UUID will trigger a 422. Add assertions to `ui/src/app/detail/detail.component.spec.ts` confirming that `updateTags` and `delete` are called with the `short_id` value (`'AbCd1234'`) not the UUID. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm new assertions pass.
- [X] T012 Update `ui/src/app/library/library.component.ts`: change `router.navigate(['/images', img.id])` (×2: click handler and keydown handler) to `router.navigate(['/i', img.short_id])`. Run `ng test --watch=false` and confirm T009 Angular tests pass.
**Checkpoint**: US1 complete. API returns `short_id` on every image response. Routes accept short IDs. Library navigates to `/i/{short_id}`.
---
## Phase 3: User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
**Goal**: When a new image is uploaded, a short ID is generated, used as the storage key (replacing the hash), and returned in the response. Deduplication by content hash still works.
**Independent Test**: Upload a new image. Confirm the response includes a `short_id` field with exactly 8 alphanumeric characters. Confirm `storage_key` equals `short_id` and `thumbnail_key` equals `{short_id}-thumb`. Upload the same image again — confirm `duplicate: true` and the same `short_id` is returned.
- [X] T013 Write failing integration tests in `api/tests/integration/test_upload.py`: (1) upload a new image → response includes `short_id` field matching `[a-zA-Z0-9]{8}`; (2) `storage_key` in response equals `short_id`; (3) `thumbnail_key` in response equals `{short_id}-thumb` (or is null for images without thumbnails); (4) upload same file twice → second response has `duplicate: true` and identical `short_id`. Run `make test-integration` and confirm new tests FAIL.
- [X] T014 Update the upload handler in `api/app/routers/images.py`: after the hash duplicate check, add collision-retry loop (up to 10 attempts): `short_id = generate_short_id()`; call `await storage.put(short_id, data, mime_type)` instead of `await storage.put(hash_hex, ...)`; call `await storage.put(f"{short_id}-thumb", ...)` instead of `f"{hash_hex}-thumb"`; pass `storage_key=short_id`, `thumbnail_key=f"{short_id}-thumb"` (or None), and `short_id=short_id` to `image_repo.create()`. Catch `IntegrityError` on `create()`, rollback, retry with new short_id. Import `generate_short_id` from `app.utils` and `IntegrityError` from `sqlalchemy.exc`. Run `make test-integration` and confirm T013 tests pass.
- [X] T015 Update `ui/src/app/upload/upload.component.ts`: change `this.router.navigate(['/images', res.id])` to `this.router.navigate(['/i', res.short_id])`. Add a test to the upload component spec (or update the existing navigation test) asserting the route uses `short_id`.
**Checkpoint**: US2 complete. All new uploads produce a short ID and are immediately accessible at `/i/{short_id}`.
---
## Phase 4: User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
**Goal**: A runnable script backfills `short_id` for all pre-existing images, copies their storage objects to the new key pattern, and updates DB records. A final Alembic migration adds the NOT NULL constraint. After this phase the system has no UUID-keyed storage objects.
**Independent Test**: Run the migration script — confirm it prints a count of migrated images and exits cleanly. Run it a second time — confirm it reports 0 migrated (idempotent). Browse the library and open pre-migration images — confirm all load with short ID URLs and no broken images.
- [X] T016 Write unit tests in `api/tests/unit/test_migration.py` covering the migration script logic: (1) an image with `short_id IS NULL` is processed (short_id generated, storage copy called, DB update called, old keys deleted); (2) an image with `short_id` already set is skipped; (3) if a storage copy fails, the error is logged and the script continues to the next image (no abort); (4) the summary at the end reports correct migrated and skipped counts. Mock the storage client and DB session. Run `make test-unit` and confirm new tests FAIL (script not yet created).
- [X] T017 Create `api/scripts/__init__.py` (empty) and `api/scripts/migrate_to_short_ids.py`: async main function that (a) reads DB URL and storage config from env vars via `app.config.get_settings()`; (b) creates an async DB session and `S3StorageBackend` instance; (c) queries all images where `short_id IS NULL`; (d) for each: generate short_id (retry on `UniqueViolation`), copy storage object using `data = await storage.get(old_key); await storage.put(new_key, data, image.mime_type)` (the `StorageBackend` interface provides only `get`/`put`/`delete` — there is no `copy` method), verify the copy succeeded by calling `await storage.get(new_key)` and catching any exception, update the DB row (`short_id`, `storage_key`, `thumbnail_key`), then delete old keys with `await storage.delete(old_key)`; (e) skips images where `thumbnail_key IS NULL` for the thumbnail copy step; (f) wraps each image in a try/except so a single failure logs an error and continues to the next image; (g) prints `Migrated: N, Skipped: M, Failed: K` on completion. Entry point: `if __name__ == '__main__': asyncio.run(main())`. Run `make test-unit` and confirm T016 tests pass.
- [X] T018 Create Alembic migration `api/alembic/versions/004_short_id_not_null.py`: `op.alter_column('images', 'short_id', nullable=False)`. **Run this migration only after the migration script completes with 0 remaining NULL rows.** Downgrade sets nullable=True. Document this ordering requirement in the migration file's docstring.
**Checkpoint**: US3 complete. All existing images have short IDs, storage objects use new key pattern, `short_id` column is NOT NULL.
---
## Phase 5: Polish & Cross-Cutting Concerns
- [X] T019 Update `api/tests/integration/test_search.py`, `test_delete.py`, `test_serving.py`, `test_tags.py`, and `test_public_access.py`: wherever tests construct a URL with `f"/api/v1/images/{image.id}"` or `f"/api/v1/images/{uuid}"`, replace with `f"/api/v1/images/{image.short_id}"`. Ensure test fixtures (conftest.py) populate `short_id` on images created for testing. Run `make test-integration` and confirm all integration tests pass.
- [X] T020 Update `ui/src/app/detail/detail.component.spec.ts`: add `short_id: 'AbCd1234'` to `MOCK_IMAGE` and `MOCK_IMAGE_ABS` constants. Update any test assertions that check navigation targets to use `short_id`. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm all tests pass.
- [X] T021 Run `ng lint` across all modified UI files and `ruff check api/app/ api/tests/ api/scripts/` across all modified API files; fix any issues. Confirm `ruff format --check api/` passes.
- [X] T022 Run `ng build --configuration production` and confirm build succeeds with no TypeScript errors. Run `make test-unit && make test-integration` and confirm all tests pass.
- [ ] T023 Manually verify all seven quickstart.md scenarios in the browser: (1) new upload navigates to `/i/{short_id}`; (2) deduplication returns same short_id; (3) library cards navigate to `/i/`; (4) tag and delete work via short_id; (5) pre-migration images accessible (after running script); (6) migration is idempotent; (7) "Copy URL" copies the CDN URL with short_id.
---
## Dependencies & Execution Order
- T001 before T002 (write failing tests before implementation)
- T002 before T003/T004/T005 (utility must exist before migration and model reference it)
- T003 before T004 (DB column must exist before model references it)
- T004 before T005 (model must have `short_id` field before repo uses it)
- T005 before T005a (conftest fixtures need the updated `create()` signature)
- T005a before T006 (integration test fixtures must have `short_id` before any integration tests run)
- T006 before T007 (write failing tests before implementation)
- T007 before T008 (helper and dict update before route param changes)
- T008 before T009/T010/T011 (API must accept short_id before frontend uses it)
- T009 before T010/T011/T011a/T012 (write failing tests before implementation)
- T010, T011, T011a, T012 can run in parallel (different files)
- T011 before T011a (interface must have `short_id` field before detail component uses it)
- T013 before T014 (write failing tests before upload changes)
- T014 before T015 (upload must produce short_id before frontend navigation uses it)
- T016 before T017 (write failing tests before script)
- T017 before T018 (script must run successfully before NOT NULL migration)
- T019T023 after T018 (polish after all implementation complete)
### Execution Order Summary
```
Step 1: T001 → T002 (generate_short_id: tests → implementation)
Step 2: T003, T004 (parallel) (Alembic 003 + model update)
Step 3: T005 (repo: get_by_short_id + create update)
Step 3a: T005a (conftest: fixture images get short_id)
Step 4: T006 → T007 → T008 (API routes: tests → dict → route params)
Step 5: T009 (Angular failing tests)
Step 6: T010, T011, T011a, T012 (parallel) (route, interface, detail caller fix, library navigation)
Step 7: T013 → T014 → T015 (upload: tests → handler → upload navigation)
Step 8: T016 → T017 → T018 (migration: tests → script → NOT NULL migration)
Step 9: T019, T020 (parallel) (test updates)
Step 10: T021, T022, T023 (lint, build, manual verification)
```
---
## Implementation Strategy
### MVP (US1 + US2 — full feature with new uploads)
1. T001T005a — foundational infrastructure + conftest fixtures
2. T006T012 (including T011a) — API routes accept short_id + frontend uses `/i/`
3. T013T015 — new uploads generate short_id
4. **STOP and VALIDATE**: upload a new image, confirm `/i/{short_id}` URL, confirm browsing works
5. T016T018 — migrate existing images
6. T019T023 — polish
### Note on Priority vs Implementation Order
US1 (P1) and US2 (P2) are implemented together before US3 (P3). The foundational phase is the true prerequisite for all three. US1 and US2 are tightly coupled in practice (you need uploads to produce short IDs before routing can be tested end-to-end), so they are sequenced rather than strictly priority-ordered.

View File

@@ -24,7 +24,7 @@ export const routes: Routes = [
import('./tags/tags.component').then((m) => m.TagsComponent),
},
{
path: 'images/:id',
path: 'i/:id',
loadComponent: () =>
import('./detail/detail.component').then((m) => m.DetailComponent),
},

View File

@@ -9,9 +9,9 @@ import { ToastService } from '../services/toast.service';
import { routes } from '../app.routes';
const MOCK_IMAGE = {
id: 'img-1', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
size_bytes: 100, width: 10, height: 10, storage_key: 'abc',
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
id: 'img-1', short_id: 'AbCd1234', hash: 'abc', filename: 'test.jpg', mime_type: 'image/jpeg',
size_bytes: 100, width: 10, height: 10, storage_key: 'AbCd1234',
thumbnail_key: null, file_url: '/api/v1/i/AbCd1234/file', thumbnail_url: null,
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
};
const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' };
@@ -39,14 +39,14 @@ describe('DetailComponent', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['funny'] }));
component.removeTag('cat');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['funny']);
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['funny']);
});
it('should call PATCH with new tag included on addTag', () => {
const { component, imgSvc } = setup();
spyOn(imgSvc, 'updateTags').and.returnValue(of({ ...MOCK_IMAGE, tags: ['cat', 'funny', 'new'] }));
component.addTag('new');
expect(imgSvc.updateTags).toHaveBeenCalledWith('img-1', ['cat', 'funny', 'new']);
expect(imgSvc.updateTags).toHaveBeenCalledWith('AbCd1234', ['cat', 'funny', 'new']);
});
it('should call DELETE and navigate to library on confirm delete', () => {
@@ -55,7 +55,7 @@ describe('DetailComponent', () => {
spyOn(router, 'navigate');
spyOn(imgSvc, 'delete').and.returnValue(of(undefined));
component.confirmDelete();
expect(imgSvc.delete).toHaveBeenCalledWith('img-1');
expect(imgSvc.delete).toHaveBeenCalledWith('AbCd1234');
expect(router.navigate).toHaveBeenCalledWith(['/']);
});

View File

@@ -211,7 +211,7 @@ export class DetailComponent implements OnInit {
removeTag(tag: string): void {
if (!this.image) return;
const updated = this.image.tags.filter((t) => t !== tag);
this.imageService.updateTags(this.image.id, updated).subscribe({
this.imageService.updateTags(this.image.short_id, updated).subscribe({
next: (img) => { this.image = img; this.tagError = ''; this.cdr.markForCheck(); },
error: (err) => { this.tagError = err?.error?.detail ?? 'Failed to remove tag'; this.cdr.markForCheck(); },
});
@@ -221,7 +221,7 @@ export class DetailComponent implements OnInit {
if (!this.image || !tag.trim()) return;
const normalised = tag.trim().toLowerCase();
const updated = [...this.image.tags, normalised];
this.imageService.updateTags(this.image.id, updated).subscribe({
this.imageService.updateTags(this.image.short_id, updated).subscribe({
next: (img) => { this.image = img; this.newTagInput = ''; this.tagError = ''; this.cdr.markForCheck(); },
error: (err) => { this.tagError = err?.error?.detail ?? 'Invalid tag'; this.cdr.markForCheck(); },
});
@@ -232,7 +232,7 @@ export class DetailComponent implements OnInit {
confirmDelete(): void {
if (!this.image) return;
this.imageService.delete(this.image.id).subscribe({
this.imageService.delete(this.image.short_id).subscribe({
next: () => this.router.navigate(['/']),
error: () => { this.showDeleteDialog = false; this.cdr.markForCheck(); },
});

View File

@@ -17,15 +17,15 @@ function makeActivatedRoute(queryParams: Record<string, string> = {}) {
const EMPTY_PAGE = { items: [], total: 0, limit: 24, offset: 0 };
const ONE_IMAGE = {
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: '/api/v1/images/1/file', thumbnail_url: null, created_at: '' }],
items: [{ id: '1', short_id: 'ShrtImg1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: 'ShrtImg1', thumbnail_key: null, file_url: '/api/v1/i/ShrtImg1/file', thumbnail_url: null, created_at: '' }],
total: 1, limit: 24, offset: 0,
};
const MULTI_PAGE = {
items: Array(24).fill(null).map((_, i) => ({
id: String(i + 1), filename: `img${i + 1}.jpg`, tags: [], hash: '',
id: String(i + 1), short_id: `Shrt${String(i + 1).padStart(4, '0')}`, filename: `img${i + 1}.jpg`, tags: [], hash: '',
mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1,
storage_key: '', thumbnail_key: null,
file_url: `/api/v1/images/${i + 1}/file`, thumbnail_url: null, created_at: '',
storage_key: `Shrt${String(i + 1).padStart(4, '0')}`, thumbnail_key: null,
file_url: `/api/v1/i/Shrt${String(i + 1).padStart(4, '0')}/file`, thumbnail_url: null, created_at: '',
})),
total: 48, limit: 24, offset: 0,
};
@@ -292,4 +292,16 @@ describe('LibraryComponent', () => {
queryParamsHandling: 'merge',
}));
});
it('clicking an image card navigates to /i/:short_id', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
fixture.detectChanges();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
const card = (fixture.nativeElement as HTMLElement).querySelector('.image-card') as HTMLElement;
card.click();
expect(router.navigate).toHaveBeenCalledWith(['/i', 'ShrtImg1']);
});
});

View File

@@ -70,8 +70,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
class="image-card"
role="button"
tabindex="0"
(click)="router.navigate(['/images', img.id])"
(keydown.enter)="router.navigate(['/images', img.id])"
(click)="router.navigate(['/i', img.short_id])"
(keydown.enter)="router.navigate(['/i', img.short_id])"
>
<img
[src]="img.thumbnail_url ?? img.file_url"

View File

@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
export interface ImageRecord {
id: string;
short_id: string;
hash: string;
filename: string;
mime_type: string;
@@ -50,14 +51,14 @@ export class ImageService {
}
get(id: string): Observable<ImageRecord> {
return this.http.get<ImageRecord>(`${this.base}/images/${id}`);
return this.http.get<ImageRecord>(`${this.base}/i/${id}`);
}
updateTags(id: string, tags: string[]): Observable<ImageRecord> {
return this.http.patch<ImageRecord>(`${this.base}/images/${id}/tags`, { tags });
return this.http.patch<ImageRecord>(`${this.base}/i/${id}/tags`, { tags });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.base}/images/${id}`);
return this.http.delete<void>(`${this.base}/i/${id}`);
}
}

View File

@@ -37,9 +37,9 @@ describe('UploadComponent', () => {
component = fixture.componentInstance;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'abc', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
await component.handleUploadResponse({ id: 'abc', short_id: 'AbCd1234', duplicate: true } as Parameters<typeof component.handleUploadResponse>[0]);
expect(component.toastMessage).toContain('library');
expect(router.navigate).toHaveBeenCalledWith(['/images', 'abc']);
expect(router.navigate).toHaveBeenCalledWith(['/i', 'AbCd1234']);
});
it('on success response: shows success toast and navigates to detail', async () => {
@@ -47,9 +47,9 @@ describe('UploadComponent', () => {
component = fixture.componentInstance;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
await component.handleUploadResponse({ id: 'xyz', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
await component.handleUploadResponse({ id: 'xyz', short_id: 'XyZ12345', duplicate: false } as Parameters<typeof component.handleUploadResponse>[0]);
expect(component.toastMessage).toBeTruthy();
expect(router.navigate).toHaveBeenCalledWith(['/images', 'xyz']);
expect(router.navigate).toHaveBeenCalledWith(['/i', 'XyZ12345']);
});
it('on error response: shows inline error, no navigation', async () => {

View File

@@ -180,7 +180,7 @@ export class UploadComponent {
this.cdr.markForCheck();
}, 4000);
}
await this.router.navigate(['/images', res.id]);
await this.router.navigate(['/i', res.short_id]);
}
handleUploadError(err: unknown): void {