Imported from TioHerko/GIFGallery (
AGENTS.md). Install upstream withnpx skills add TioHerko/GIFGallery. Copyright stays with the author.
GIF Gallery
Multi-user Django app for hosting, tagging, and sharing GIFs.
Project structure
gif/ # repo root
pyproject.toml # uv-managed, deps: django, nanoid, uvicorn, aiofiles, pillow
Dockerfile # production container (uvicorn + SQLite on /data volume)
nginx.conf.example # production nginx template
gif/ # Django root (contains manage.py)
gif/ # Django project config (settings, urls, wsgi, asgi)
gallery/ # main app (models, views, templates, static, auth, thumbnails)
media/ # uploaded GIF files (gitignored)
staticfiles/ # collectstatic output (gitignored)
clients/
GIFKit/ # shared Swift package (API client, models, viewmodel, keychain)
macos/ # macOS app ("GIF Lobster") — SwiftUI + App Intents
ios/ # iOS app — SwiftUI + share extension
.github/workflows/
docker.yml # multi-arch Docker build on push to main
swift.yml # macOS app build, sign, notarize, DMG on push to main
Commands
All commands run from gif/gif/ (the directory with manage.py):
uv run uvicorn gif.asgi:application --reload # dev server (ASGI)
uv run python manage.py test # run tests
uv run python manage.py makemigrations # after model changes
uv run python manage.py migrate # apply migrations
uv run python manage.py createsuperuser # create extra superuser accounts
uv run python manage.py collectstatic # for production
Swift client build commands (from clients/macos/):
./build.sh # debug build + ad-hoc sign
./build-release.sh --arch x86_64 --arch arm64 # release build (CI)
iOS sideload (from clients/ios/):
export DEVELOPMENT_TEAM=XXXXXXXXXX
./sideload.sh <device-udid> # archive + install
Key design decisions
- Single Django app (
gallery) — no need for multiple apps - SQLite database — no concurrency concerns
- Multi-user: Each user owns their own GIFs via
Gif.ownerFK. Tags are shared globally.gallery_viewandapi_list_gifsfilter byowner=request.user; GIF-serving endpoints (/gif/<id>/,/thumb/<id>/) are public (no auth). - Nanoid IDs (12-char) on
Gifmodel instead of UUIDs — shorter URLs, usesnanoidlibrary - Tags: simple
Tagmodel with M2M, not django-taggit — avoids unnecessary dependency - Sorting:
gallery_viewaccepts?sort=from theGALLERY_SORTSwhitelist inviews.py(newest— the default —oldest,az,za,most_used,least_used), remembers it in the session (asyncsession.aget/aset), and ignores unknown values. Titles sort throughLower()since SQLite compares text binary; the usage sorts tiebreak on-created_at. Cards showcopy_countonly undermost_used/least_used. The Swift clients re-sort the fetched list inGalleryViewModelviaGallerySort(raw values match the server's keys), so sorting there costs no round trip and/api/gifs/needs no?sort=. - Auth: Django built-in
LoginView+/setup/signup page. On first run (empty user table)/login/redirects to/setup/, which creates the first account as a superuser and logs in; subsequent signups via/setup/create regular users. No separate signup view. Extra superuser accounts viamanage.py createsuperuser - GIF serving (
/gif/<id>/): public (no auth), returnsCache-Control: public, max-age=31536000, immutable - Production hardening is gated on
DJANGO_SECRET_KEY: setting it enables Secure cookies, HSTS, andSECURE_PROXY_SSL_HEADER; without it the app runs in dev mode with a public fallback key. The Docker container refuses to start without it. - Uploads are validated: GIF magic bytes +
GIF_MAX_UPLOAD_BYTESsize cap, enforced inupload_view - Bearer-token requests never fall back to session auth (they're CSRF-exempt, so session fallback would be a CSRF bypass — see
gallery/auth.py) - Async views must resolve
request.user:request.useris aSimpleLazyObjectthat triggers synchronous DB access when resolved. In async views, useawait request.auser()or rely onauth_required(which setsrequest.user = userfor both bearer and session auth).gallery_viewuses@login_required(synchronous) so it manually resolves viaawait request.auser(). - CDN assets are version-pinned with SRI hashes in
base.html; upgrading requires recomputing the integrity hash - Gallery/upload: requires authentication
- UI: DaisyUI v5 + Tailwind CSS v4 via CDN, dark theme (
data-theme="dark") - No REST API / DRF — server-rendered templates with minimal vanilla JS
- API tokens: Created via
/settings/, stored as SHA-256 hashes inAPITokenmodel. Raw token shown once, then never retrievable. Bearer auth viaAuthorization: Bearer <token>header.
URL scheme
| URL | Auth | Purpose |
|---|---|---|
/ |
Yes | Gallery with tag filter, search and sort (?tag=, ?q=, ?sort=) |
/gif/<id>/ |
No | Embed page (public, shows GIF + sharing links) |
/gif/<id>.gif |
No | Serve GIF file (public, cached) |
/thumb/<id>.gif |
No | Serve thumbnail (public, cached; falls back to full GIF) |
/gif/<id>/tags/ |
Yes | POST to update tags (JSON response) |
/gif/<id>/rename/ |
Yes | POST to rename (JSON response) |
/gif/<id>/copy/ |
Yes | POST to increment copy counter (JSON response) |
/gif/<id>/delete/ |
Yes | POST to delete GIF (JSON response) |
/upload/ |
Yes | Multi-file upload with drag-drop |
/settings/ |
Yes | Change password, create/list/delete API tokens |
/settings/password/ |
Yes | POST to change password |
/settings/tokens/create/ |
Yes | POST to create API token |
/settings/tokens/<id>/delete/ |
Yes | POST to delete API token |
/api/gifs/ |
Yes | JSON list of GIFs (used by Swift clients; supports ?tag= and ?q=) |
/login/ |
No | Login form (redirects to /setup/ while no account exists) |
/setup/ |
No | First-run or signup page (first user becomes superuser, subsequent users are regular) |
/logout/ |
Yes | Logout |
/admin/ |
Staff | Django admin |
Data models
- Gif: Nanoid PK,
ownerFK to User,title,file(FileField),thumbnail(FileField, optional),tagsM2M to Tag,copy_count,created_at. Ordered by-created_at(newest first). - Tag: Auto-increment PK,
name(unique),slug(unique). Ordered byname. - APIToken: Auto-increment PK,
userFK,name(label),token_hash(SHA-256, unique),created_at. Raw token is never stored.
Auth architecture
Two auth paths in gallery/auth.py:
-
Bearer token:
Authorization: Bearer <token>header →_get_bearer_user()hashes the token and looks upAPIToken→ setsrequest.userdirectly. CSRF-exempt via middleware. -
Session auth: Fallback when no Bearer header →
await request.auser()→ checkis_authenticated. Setsrequest.user = userto avoid the lazy object trap.
The auth_required decorator is used on all async views that need auth. It returns JSON {"error": "..."} with 401, not redirects — the Swift clients use the API endpoints exclusively.
The @login_required decorator (sync) is used only on gallery_view (server-rendered HTML) and settings_view/change_password_view/create_token_view/delete_token_view (sync views). It redirects to /login/ for unauthenticated users.
Thumbnails
gallery/thumbnails.py generates animated GIF thumbnails using Pillow:
- Max width: 320px (scaled proportionally)
- Frame rate halved (every other frame, durations merged)
- 256-color palette quantization for smaller files
- Generated on upload (in
upload_view) and viamanage.py generate_thumbnails
Swift clients
The shared GIFKit package provides:
APIClient: All API calls (list, upload, rename, tag, delete, copy). UsesURLSessionwith a large persistent cache.GalleryViewModel:@Observableview model shared by macOS and iOS apps. Holds thesortOrder(persisted in the shared defaults suite) and exposessortedGIFs; views iterate that, notgifs.GallerySort: The six sort strategies, with raw values matching the server's?sort=keys. Sorting happens locally over the fetched list, so switching order needs no refetch. NamedGallerySortbecause Foundation already has aSortOrder.showsUsageCountdrives the count badge onGIFGridItem.KeychainStore: Stores bearer token in the keychain (not UserDefaults). Supports app group sharing.SharedStore: App group configuration (server URL, token). On macOS, derives the Team ID from the code signature at runtime.GIFIngest: Validates GIF magic bytes on incoming share-sheet items before upload.
GIFKit has a test target (cd clients/GIFKit && swift test), run in CI before the macOS build.
The macOS app includes App Intents (Shortcuts): Find GIFs, Random GIF, Upload GIFs. The GIFLobsterIntents.swift file is duplicated between macOS and iOS (not shared via GIFKit) because App Intents metadata extraction requires the file to be in the app module.