Private
Public Access
Site-wide, any user can upload -- usable both as reactions and inline in message text via :shortcode:, alongside the existing built-in Unicode picker. A :shortcode: reference is stored/sent as literal text (same as the built-in shortcode convention) and resolved to an image at render time, so it degrades to plain text if the emoji is later deleted. Backend: new custom_emoji table (shortcode unique, sized to fit MessageReaction.emoji's existing column alongside its colons), upload/ list/delete endpoints (delete restricted to uploader or site admin). Frontend: a CustomEmojiProvider context feeds a new "Custom" category in the emoji picker (inline upload + hover-to-remove), extends the composer's shortcode autocomplete, and a shared EmojiGlyph resolver renders custom emoji wherever a value can appear -- message text, reaction pills, and the picker itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
132 lines
4.8 KiB
Python
132 lines
4.8 KiB
Python
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.dependencies import get_current_user
|
|
from app.models import User
|
|
from app.schemas.custom_emoji import CustomEmojiRead
|
|
from app.services.custom_emoji_service import (
|
|
SHORTCODE_PATTERN,
|
|
CustomEmojiNotFoundError,
|
|
DuplicateShortcodeError,
|
|
InvalidShortcodeError,
|
|
NotEmojiOwnerError,
|
|
create_custom_emoji,
|
|
delete_custom_emoji,
|
|
get_custom_emoji_by_shortcode,
|
|
list_custom_emoji,
|
|
)
|
|
from app.services.upload_settings_service import format_mb, get_upload_settings
|
|
from app.storage import (
|
|
ALLOWED_IMAGE_CONTENT_TYPES,
|
|
UPLOADS_DIR,
|
|
InvalidImageError,
|
|
UploadTooLargeError,
|
|
process_image,
|
|
read_capped,
|
|
save_file,
|
|
)
|
|
|
|
# Small and square -- these render inline in message text/reaction pills at
|
|
# roughly text size, nowhere near message-image or avatar dimensions.
|
|
CUSTOM_EMOJI_MAX_DIMENSION = 128
|
|
|
|
router = APIRouter(prefix="/api/custom-emoji", tags=["custom-emoji"])
|
|
|
|
|
|
@router.post("", response_model=CustomEmojiRead, status_code=201)
|
|
async def upload_custom_emoji_endpoint(
|
|
shortcode: str = Form(...),
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
shortcode = shortcode.strip().lower()
|
|
# Checked here, before any file processing/saving, so the common
|
|
# rejection cases (bad format, name taken) never leave an orphaned
|
|
# file on disk -- create_custom_emoji below still re-checks both
|
|
# (the actual source of truth, and the only thing that closes the
|
|
# TOCTOU race on the uniqueness check).
|
|
if not SHORTCODE_PATTERN.match(shortcode):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Shortcode must be 2-30 characters: lowercase letters, numbers, hyphens, underscores",
|
|
)
|
|
if await get_custom_emoji_by_shortcode(db, shortcode) is not None:
|
|
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
|
|
|
|
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
|
raise HTTPException(status_code=400, detail="Unsupported image type")
|
|
|
|
upload_settings = await get_upload_settings(db)
|
|
try:
|
|
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
|
|
except UploadTooLargeError:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
|
|
)
|
|
|
|
try:
|
|
data, ext = process_image(
|
|
data, file.content_type, square=True, max_dimension=CUSTOM_EMOJI_MAX_DIMENSION
|
|
)
|
|
except InvalidImageError:
|
|
raise HTTPException(status_code=400, detail="File is not a valid image")
|
|
|
|
storage_filename = save_file(data, ext)
|
|
try:
|
|
return await create_custom_emoji(
|
|
db, current_user.id, shortcode, storage_filename, file.content_type
|
|
)
|
|
except (InvalidShortcodeError, DuplicateShortcodeError):
|
|
# Already checked above -- only reachable via the uniqueness
|
|
# check's TOCTOU race (two uploads of the same new shortcode at
|
|
# once), not the common case.
|
|
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
|
|
|
|
|
|
@router.get("", response_model=list[CustomEmojiRead])
|
|
async def list_custom_emoji_endpoint(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return await list_custom_emoji(db)
|
|
|
|
|
|
@router.delete("/{emoji_id}", status_code=204)
|
|
async def delete_custom_emoji_endpoint(
|
|
emoji_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
try:
|
|
await delete_custom_emoji(db, emoji_id, current_user)
|
|
except CustomEmojiNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Custom emoji not found")
|
|
except NotEmojiOwnerError:
|
|
raise HTTPException(status_code=403, detail="Only the uploader or a site admin can remove this")
|
|
|
|
|
|
@router.get("/{shortcode}/image")
|
|
async def get_custom_emoji_image_endpoint(
|
|
shortcode: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
emoji = await get_custom_emoji_by_shortcode(db, shortcode)
|
|
if emoji is None:
|
|
raise HTTPException(status_code=404, detail="Custom emoji not found")
|
|
return FileResponse(
|
|
UPLOADS_DIR / emoji.storage_filename,
|
|
media_type=emoji.content_type,
|
|
# Site-wide and rarely changed, but a shortcode can be deleted and
|
|
# re-uploaded with different image data -- short-cache like the
|
|
# avatar endpoint, not `immutable` like content-addressed message
|
|
# images.
|
|
headers={"Cache-Control": "private, max-age=300"},
|
|
)
|