Photo System

The photo system provides multi-photo management for any entity (users, events, locations, mailing lists). It consists of three layers: a polymorphic data model, theme-driven image sizing, and a reusable UI helper.


Architecture Overview

┌──────────────────────────────────────────────────────────────┐
│  Views (admin or public)                                     │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │ PhotoHelper::render_photo_card('grid', 'event', $id...) │ │
│  │ PhotoHelper::render_photo_scripts('grid', 'event', $id) │ │
│  └─────────────────────────────────────────────────────────┘ │
│                          │                                    │
│                          ▼                                    │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │ AJAX: /ajax/entity_photos_ajax                          │ │
│  │   upload, delete, reorder, update_caption               │ │
│  └─────────────────────────────────────────────────────────┘ │
│                          │                                    │
│                          ▼                                    │
│  ┌────────────────────┐  ┌──────────────────┐                │
│  │ EntityPhoto model   │  │ File model       │                │
│  │ (eph_entity_photos) │  │ (fil_files)      │                │
│  └────────────────────┘  └──────────────────┘                │
│                          │                                    │
│                          ▼                                    │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │ ImageSizeRegistry                                       │ │
│  │   Reads image_sizes from theme.json → drives File resize │ │
│  └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

Key Files

FilePurpose
includes/PhotoHelper.phpReusable UI rendering (HTML + JS)
includes/ImageSizeRegistry.phpTheme-driven image size definitions
data/entity_photos_class.phpPolymorphic photo-entity association model
data/files_class.phpFile storage, resizing, URL generation
ajax/entity_photos_ajax.phpAJAX endpoint for upload/delete/reorder/caption

File Storage Directories

Files live in one of two local directories based on their permission settings:

DirectoryContentsServing Speed
static_files/uploads/Public files (no permission restrictions)~1.5ms (pre-bootstrap fast path)
uploads/Restricted files (have group, event, or permission restrictions)~20ms (full PHP auth)
Both directories maintain the same internal structure (original, thumb/, avatar/, etc.). File::save() automatically moves files between directories when permissions change. File::get_url() always returns /uploads/... URLs regardless of which directory the file is in — RouteHelper checks static_files/uploads/ first and serves the file without loading the PHP bootstrap if found there.

A file's physical bytes live in a FileBlob (fbb_file_blobs) that the File references; two files with identical bytes and the same visibility share one blob (content dedup). Physical operations — paths, reads, resize, offload, deletion — delegate to the blob; File owns only identity, ownership, and the visibility gates.

Public files may also live in a customer-owned cloud bucket. When cloud storage is enabled (admin page at /admin/admin_cloud_storage), public blobs are asynchronously moved to a configured S3-compatible bucket; their bytes leave local disk and the blob's fbb_storage_driver flips from 'local' to 'cloud'. URL generation, deletion, re-resize, and the public→private permission-flip pull-back all dispatch on the per-blob driver flag. Private files stay local regardless. See Cloud Storage for full architecture, settings, migration, and CDN recommendations.

Key methods:

  • File::is_public() — checks if a file has no permission restrictions
  • File::get_filesystem_path($size_key) — finds the file's bytes (via its blob) in whichever directory they live in
  • File::move_to_correct_directory() — maintains the visibility invariant (called by save()): flips a refcount-1 blob's placement, or copy-on-write-splits a shared blob so the changed file gets its own
See specs/implemented/fast_serve_uploads.md for the full specification.


File Origin (fil_source)

Every File row carries an optional fil_source tag — a short string naming where the file came from, stamped by whatever code created it. It is a categorization label only: it has no bearing on access, which flows entirely through is_viewable() / fil_private.

The value is opaque to File — it stores and filters on the string but attaches no behavior to any value. Core's tags are File::SOURCE_* constants declared in File::core_source_catalog(); a plugin declares its own under fileSources in plugin.json and keeps the constant on one of its classes (see Drive § Origin tags and the Plugin Developer Guide).

Source keyConstantSet at
user_uploadFile::SOURCE_USER_UPLOADgeneral admin/user upload (admin_file_upload_process_logic.php)
entity_photoFile::SOURCE_ENTITY_PHOTOavatar / event / location / gallery photo (entity_photos_ajax.php)
driveFile::SOURCE_DRIVEmember Drive item — the whole Drive surface (listings, trash, purge, quota) scopes to this tag
problem_reportFile::SOURCE_PROBLEM_REPORTimage a member attached to a problem report this site sends (report_problem_submit_logic.php)
email_attachmentAttachmentByteCustody::FILE_SOURCE (mailbox)inbound-email attachment (InboundEmailRouter.php)
mailbox_search_indexMailboxIndex::FILE_SOURCE (mailbox)sealed FTS5 blob (MailboxIndex), read server-side only
mail_import_archiveMailImportService::FILE_SOURCE (mailbox)mbox/zip/tar uploaded to be imported into a mailbox (MailImportService)
messenger_attachmentMessengerUploads::FILE_SOURCE (messenger)photo or file sent in a conversation
ai_chat_uploadAiAttachment::FILE_SOURCE (joinery_ai)file uploaded into a joinery_ai chat
bug_report_imageBugReportIntake::FILE_SOURCE (bug_reports)image that arrived with a problem report from another site
boards_attachmentBoardsUploads::FILE_SOURCE (boards)file attached to a card, or a board's background image
The tag is what keeps surfaces from bleeding into each other. Drive lists only drive, so a mail attachment or an import archive never appears there or counts against a member's quota, even though all three belong to the same user.

NULL means "unspecified / legacy." New file-creation sites should stamp a source. Filter a MultiFile collection with source (exact match), sources (any of several — for a surface spanning more than one origin, e.g. the mail-import picker offering Drive items alongside prior archives), or source_not (everything except one source — legacy NULL files are always kept). The admin files browser (/admin/admin_files) exposes an Exclude email attachments scope built on source_not.

Size lives on the blob, not the file. There is no fil_size column — several files can share one set of bytes through dedup, so the byte count belongs to fbb_size_bytes. Use File::size_bytes(); reading fil_size yields nothing silently.


EntityPhoto Data Model

Table: eph_entity_photos

Polymorphic association linking any entity type to files. Uses eph_entity_type (string) + eph_entity_id (int) to identify the owning entity.

// Load all photos for an entity
$photos = new MultiEntityPhoto([
    'entity_type' => 'event',
    'entity_id' => $event->key,
    'deleted' => false
]);
$photos->load();

// Get the primary photo for an entity
$primary = EntityPhoto::get_primary('user', $user_id);

// Create a new association
$photo = new EntityPhoto(NULL);
$photo->set('eph_entity_type', 'event');
$photo->set('eph_entity_id', $event_id);
$photo->set('eph_fil_file_id', $file_id);
$photo->set('eph_is_primary', true);
$photo->save();

The save() method enforces the max_entity_photos setting (JSON object with per-entity-type limits, e.g. {"user": 6, "event": 10}).

Entity Photo Methods

All four entity types have the same photo methods added to their model classes:

MethodDescription
set_primary_photo($photo_id)Clears old primary, sets new one, syncs the legacy FK column
clear_primary_photo()Clears all primaries, nulls the legacy FK column
get_photos()Returns MultiEntityPhoto for this entity (non-deleted, ordered by sort_order)
get_primary_photo()Returns the primary EntityPhoto or null
get_picture_link($size_key)Returns the URL for the primary photo at the given size, or a default fallback
Entity-specific defaults for get_picture_link():

EntityDefault SizeFallback Image
User'avatar'/assets/images/blank-avatar.png
Event'original'null
Location'content'null
MailingList'content'null
Legacy FK sync: When set_primary_photo() is called, it also updates the entity's legacy FK column (usr_pic_picture_id, evt_fil_file_id, loc_fil_file_id, mlt_fil_file_id) so that existing code using those columns continues to work.


ImageSizeRegistry

Reads image_sizes from the active theme's theme.json and provides them to File::resize().

require_once(PathHelper::getIncludePath('includes/ImageSizeRegistry.php'));

// Get all registered sizes
$sizes = ImageSizeRegistry::get_sizes();

// Get a specific size
$avatar = ImageSizeRegistry::get_size('avatar');
// Returns: ['width' => 80, 'height' => 80, 'crop' => true, 'quality' => 90]

// Check if a size exists
if (ImageSizeRegistry::has_size('hero')) { ... }

Default Sizes (Falcon theme)

Defined in theme/falcon/theme.json under image_sizes:

KeyWidthHeightCropUse Case
avatar8080YesUser avatars, small thumbnails
profile_card400500YesPhoto grid items, profile cards
content8000NoIn-content images (auto height)
hero12000NoHero/banner images (auto height)
og_image1200630YesSocial sharing / Open Graph
Themes can override or add sizes in their own theme.json. The active theme's sizes are merged on top of Falcon's (which always loads as the base).

How sizes are made

File::resize() (and ensure_variant(), which makes one size the first time it is asked for) runs on the file's blob. The blob opens its original once through ImageDecoder, cuts every registered size from that one decoded image, and frees it; the whole sequence runs inside one ImageWorkLock::run() hold.

ImageDecoder (includes/ImageDecoder.php) is the only place the platform turns an image file into pixels; nothing else calls an imagecreatefrom* reader (tests/unit/core_api_mechanical_test.php pins this). ImageDecoder::open($path, $max_w, $max_h) returns the image upright at no more than is needed to cut $max_w × $max_h from it, where that pair is ImageSizeRegistry::max_dimensions() — the largest width and the largest height across the registered sizes — so every crop and scale comes out exactly as it would from the full image. In order:

  1. The header only — dimensions, type, EXIF orientation, and for a JPEG whether it is progressive (SOF2 marker).
  2. The cost — 4 bytes per decoded pixel, plus 6 per source pixel for a progressive JPEG, whose decoder keeps every coefficient until the last scan. Over the ceiling, the decode is refused before any memory is spent.
  3. JPEG, shrunk while decoding — the smallest M/8 that keeps the needed size, decoded by djpeg -scale M/8 (libjpeg-turbo's decoder as a program, the same library GD links, from libjpeg-turbo-progs) into a BMP that GD reads. A 24-megapixel photo costs about 15 MB this way instead of 92. The child runs under an address-space limit of the ceiling plus headroom. Without djpeg on the machine, logged once, the JPEG is decoded in full.
  4. Other formats decode in full under the same ceiling; none has a shrink-on-decode.
  5. Upright — EXIF orientations 2–8 are applied to the decoded, already small, image. Every variant is upright by its pixels, on every upload path.
The stored original is the uploaded bytes, EXIF and all; nothing re-saves it. Browsers and the native apps' image stacks honour the orientation tag when they show an original. A consumer that reads raw pixels from an original must orient them itself.

The ceiling is the smaller of the setting image_decode_max_mb (File hosting; default 160 MB) and what the machine can spare: its memory budget (a container's cgroup limit, else the machine's total) less PostgreSQL's share (a fifth, 64–2048 MB) and the 128 MB the rest of a site needs — the split sysadmin_tools/_memory_plan.sh sizes PHP-FPM by, never under 24 MB. A 256 MB site allows 64 MB, so it takes any shrunk JPEG and refuses a 24-megapixel progressive one (~150 MB) or a 24-megapixel PNG (92 MB); a 512 MB site allows the full 160. A decode over the ceiling throws ImageDecodeRefused with the estimate and the bound in words (which one bound it: the setting, or this site's memory); resize() logs it, records it in the blob's fbb_variant_refused, and returns false. A djpeg child killed by a signal (the kernel's OOM killer) is a transient refusal: logged, nothing recorded, tried again on the next request. resize() and ensure_variant() return false at once while the record is set, so a listing page does not decode the same photo on every row. The original is kept and served. The upload responses (admin uploader, Drive, entity photos) carry the reason as warning. utils/regenerate_image_sizes.php clears the record before trying each file again, which is how a raised ceiling takes effect, and counts the ones refused again.

Memory, measured at 24 megapixels: a baseline JPEG opens at about 15 MB; a progressive JPEG at about 150 MB (the coefficient buffer, inside the djpeg child); a PNG at 92 MB. memory_get_peak_usage() sees none of it.

One decode at a time. ImageWorkLock::run() is a site-wide flock on cache/image_work.lock. FileBlob::_render_sizes() holds it from the decode until the decoded image is freed, once per resize. A second upload's decode waits its turn rather than doubling the memory. A process that dies holding the lock releases it. A decode that waits more than 60 seconds logs it and runs without the lock, so no photo is left without its sizes. A nested run() goes straight through.


PhotoHelper

includes/PhotoHelper.php — A static utility class that renders photo management UI. Handles its own require_once for files_class.php and entity_photos_class.php.

Usage

Two method calls per page — one for the HTML card, one for the JavaScript:

require_once(PathHelper::getIncludePath('includes/PhotoHelper.php'));

// In the page body: render the photo card
PhotoHelper::render_photo_card('grid', 'event', $event->key, $event_photos, [
    'set_primary_url' => '/plugins/event_manager/admin/admin_event?evt_event_id=' . $event->key,
    'card_title' => 'Event Photos',
    'editable' => $can_edit,
]);

// Before </body> or in script section: render the JavaScript
PhotoHelper::render_photo_scripts('grid', 'event', $event->key, [
    'set_primary_url' => '/plugins/event_manager/admin/admin_event?evt_event_id=' . $event->key,
    'confirm_delete_msg' => 'Remove this photo from the event?',
]);

Parameters

PhotoHelper::render_photo_card($mode, $entity_type, $entity_id, $photos, $options);
PhotoHelper::render_photo_scripts($mode, $entity_type, $entity_id, $options);

ParameterTypeDescription
$modestring'grid' (multi-photo) or 'single' (future)
$entity_typestringEntity type: 'event', 'user', 'location', 'mailing_list'
$entity_idintEntity primary key
$photosMultiEntityPhotoFrom $entity->get_photos()
$optionsarraySee options table below

Options

KeyTypeDefaultDescription
set_primary_urlstring''URL the set-primary form POSTs to (required)
card_titlestring'Photos'Card header text
image_sizestring'profile_card'ImageSizeRegistry key for display
confirm_delete_msgstring'Remove this photo?'Confirm dialog text
editablebooltrueShow upload/delete/reorder/set-primary controls
aspect_ratiostring'4/5'CSS aspect-ratio for photo thumbnails
empty_messagestring'No photos yet'Empty state text

Display Modes

grid mode (implemented):

  • Bootstrap card with photo grid (.row.g-2, .col-4.col-md-3)
  • Upload button in card header
  • Drag-and-drop reorder
  • Star icon overlay: solid gold = primary, outline = click to set
  • X icon overlay: click to delete (with confirm dialog)
  • Empty state with muted icon and message
single mode (stub, not yet implemented):
  • For entities that only need one photo (locations, mailing lists)
  • Will be implemented when an admin page needs it

Element ID Namespacing

All element IDs are namespaced with joinery-photo-{entity_type}-{entity_id} to avoid collisions with other frameworks and to support multiple PhotoHelper instances on the same page.

ElementID Pattern
Grid containerjoinery-photo-grid-{type}-{id}
Upload buttonjoinery-photo-upload-btn-{type}-{id}
File inputjoinery-photo-upload-input-{type}-{id}
Empty messagejoinery-photo-empty-{type}-{id}
CSS classes used for event delegation (.joinery-photo-item, .joinery-photo-set-primary-btn, .joinery-photo-delete-btn) use the joinery-photo- prefix without entity suffixes since JS scopes queries to the grid container.

CSS Requirements

PhotoHelper uses standard Bootstrap 5 classes (btn-primary, card, row, col-*, position-absolute, etc.) and Font Awesome icons (fas fa-star, far fa-star, fas fa-times-circle, fas fa-plus, fas fa-images, fas fa-image). It does not use Falcon-specific CSS classes, so it works on both admin and public pages.


AJAX Endpoint

File: ajax/entity_photos_ajax.php

All photo operations (except set-primary) go through this endpoint via fetch() POST.

Actions

ActionParametersDescription
uploadentity_type, entity_id, fileUpload image, create File + EntityPhoto records
deleteentity_type, entity_id, photo_idSoft-delete the EntityPhoto and its File
reorderentity_type, entity_id, photo_ids[]Update eph_sort_order for all photos
update_captionentity_type, entity_id, photo_id, captionUpdate eph_caption field

Authorization

The check_photo_permission() function grants access when any of these conditions are met:

  1. User has admin permission (>= 5)
  2. User owns the file being operated on (fil_usr_user_id matches session)
  3. User is managing their own entity (entity_type === 'user' and entity_id matches session user)

Set Primary

Set-primary uses a form POST (not AJAX) because it needs to update the entity model and redirect with a flash message. The logic file for the page handles the set_primary_photo and clear_primary_photo POST actions.


Adding Photos to a New Entity Type

To add photo support to a new entity type:

1. Add photo methods to the entity model

Add these five methods to the entity class (copy from User or Event as a template):

function set_primary_photo($photo_id) { ... }
function clear_primary_photo() { ... }
function get_photos() { ... }
function get_primary_photo() { ... }
function get_picture_link($size_key = 'content') { ... }

2. Register the entity type

ajax/entity_photos_ajax.php only serves registered entity types — an unregistered type is rejected. Core types register in EntityPhotoRegistry::registerCoreDefaults() (bottom of includes/EntityPhotoRegistry.php); a plugin's types register from its serve.php:

EntityPhotoRegistry::register('event', 'Event', 'plugins/event_manager/data/events_class.php');

The arguments are the entity type string, the model class name, and the class file path (used to lazy-load the model when syncing primary photos).

3. Add set_primary POST handler to the page's logic file

if (isset($post_vars['action']) && $post_vars['action'] == 'set_primary_photo') {
    $entity = new MyEntity($entity_id, TRUE);
    $entity->set_primary_photo((int)$post_vars['photo_id']);
    // DisplayMessage + redirect
}

if (isset($post_vars['action']) && $post_vars['action'] == 'clear_primary_photo') {
    $entity = new MyEntity($entity_id, TRUE);
    $entity->clear_primary_photo();
    // DisplayMessage + redirect
}

4. Load photos in the logic file

$page_vars['entity_photos'] = $entity->get_photos();

5. Add PhotoHelper to the view

require_once(PathHelper::getIncludePath('includes/PhotoHelper.php'));

PhotoHelper::render_photo_card('grid', 'my_entity', $entity->key, $page_vars['entity_photos'], [
    'set_primary_url' => '/admin/admin_my_entity?id=' . $entity->key,
    'card_title' => 'Photos',
]);

// ... rest of page ...

PhotoHelper::render_photo_scripts('grid', 'my_entity', $entity->key, [
    'set_primary_url' => '/admin/admin_my_entity?id=' . $entity->key,
]);

6. Update max_entity_photos setting (if needed)

The max_entity_photos setting is a JSON object. Core entity types add a key to the core default in settings.json; plugin-owned types merge their keys into the stored value from the plugin's activation hook (see plugins/event_manager/activate.php for the pattern):

{"user": 6, "my_entity": 10}

7. Run data migration (if the entity has existing photos in a legacy FK column)

Create a migration that copies existing FK references into eph_entity_photos rows.


Current Usage

PageModeEntity TypeFile
Admin Eventgrideventplugins/event_manager/admin/admin_event.php
Account Edit (public)griduserviews/profile/account_edit.php
Future candidates: plugins/event_manager/admin/admin_location.php (single), admin_mailing_list.php (single), dating profile edit (grid).