2.60.2 - 2026-10-10

No migration. Ships PR #922 (media viewer on a small screen, swipe between files, "Invert two-finger pan") and a follow-up translation fix from its review.

Added

  • A per-user "Invert two-finger pan" switch on the profile settings page (Annotation tools). The browser reports trackpad fingers after the OS's scroll direction is applied, so no default suits every machine; every viewer canvas (plain, burned, live layer, board) now carries the flag.
  • Swipe between files on a fully zoomed-out picture (ViewerSwipe, listening for Fresco's fresco:swipe); off while the annotation tools are up.

Changed

  • The viewer's controls fit a small screen. Fresco's nav is a short row (zoom, clockwise turn, eye, pencil) with the rest behind its ⋯ button; the style-panel switch joins that row; previous/next are edge tabs.
  • Requires Fresco ~> 0.13.3 and Etcher ~> 0.21.0.

i18n

  • Ukrainian: the two "Invert two-finger pan" strings were untranslated; now filled in.

2.60.1 - 2026-10-09

No migration. Follow-up to 2.60.0's "Fix all issues" (a change that was not in the release).

Fixed

  • "Fix all issues" no longer reports a file as intact when it is not. "Problems left" counted only copies that failed verification, so a file with no original record at all, or one that lacked a size, read as fine. It now counts failing renditions together with those the file lacks or has made from another spec (FileReport.problem_count/3), and a reconciliation that could not finish. An original with no record is reported as unrecoverable and nothing is made from it.
  • What the reconciler alone did is in the trail. A size it made, a copy it placed or took away was dropped from the report and from the storage.file.repaired entry; each is now an action of its own (made, copied, removed). A run that changes nothing still writes no entry.

2.60.0 - 2026-10-09

Migration V216 (run mix phoenix_kit.update). It adds one column, key_levels smallint NOT NULL DEFAULT 1, to phoenix_kit_storage_profiles; no existing file or key changes.

Added

  • A file has a storage page at /admin/media/:uuid/storage (media.manage): the checksum of the original, a table of renditions (wanted against stored, state, checksum, buckets, when each was made), Verify renditions (grouped by bucket), Make what is missing, Regenerate all, and the unedited original (download, restore, delete). A "Library and profiles" card names the file's library, storage profile and rendition profile, and whether the file was last placed by the profile's current version.
  • Fix all issues on that page (Storage.FileRepair): every copy is read back, a missing or damaged copy is replaced from another bucket's good one (the original too), buckets that hold an object no row names are recorded, a size is made again from the original when no good copy is left, and an original with none is reported as unrecoverable. It does not reconcile after a copy it could not put right.
  • A repair trail. Each damaged copy found by Verify or the fixer is a permanent storage.copy.damaged Activity entry against its bucket, each repair a storage.file.repaired one. Settings → Media → History has a "Damage and repairs" view (filter by kind, library, bucket), a file's storage page a Storage log, and a bucket's page warns about the damaged copies of the last 30 days. Files in a user's private library are not written to the site's log.
  • A folder layout choice for storage profiles (V216, Storage.KeyLayout): 0 to 3 two-character hash folders between a library's prefix and the file's MD5. New uploads follow their library's profile; a file keeps the folder stored on its row. The profiles tab shows how many file folders the busiest folder holds from 10 thousand to 100 million files.
  • Ukrainian (uk) translations (#919).

Changed

  • A file's page is the media view. /admin/media/:uuid redirects to /admin/media?file=<uuid> (or the page of the library the file is in), keeping ?annotation=. The viewer sidebar is "Media details": status, uploader, update time, file uuid, PDF pages, tags (saved with the title), and Move to trash / Restore / Delete forever. The "Storage" link, the uploader and the file uuid are shown to holders of media.manage only.
  • The header over a file's page names its library: Media / [library] / <file>.

Fixed

  • Sitemap: Publishing posts are listed under each language's own url_slug (#920, #921), the address the page itself canonicalises to, instead of the primary language's slug; a post with neither slug nor path is skipped.
  • "Delete forever" in the viewer deleted nothing when the file was opened from outside the trash view (a link, the storage page); it only said "File moved to trash". A trashed file in scope is now deleted.
  • A repair could leave the only good copy to a reconciler's mercy. When a copy could not be put right it was still counted healthy, so the reconciler ran and could unlink a good copy in a bucket the profile no longer names. It now reports the failure and does not reconcile.
  • A duplicate upload that heals a file's missing instance row crashed (MatchError) under a profile with 0 hash folders, and wrote the original under a wrong key with 2 or 3. The MD5 is the last segment of the folder, whatever the layout.
  • Repair and regeneration preserve keys shared with another file. A rebuilt rendition gets its own key when another file still references the usual one. Repairs share the reconciler's per-file lock, reload the edit state, survive leaving the page, verify unrecorded objects before adopting them, and skip writes to disabled buckets. Unresolved copies prevent reconciliation; an unsuccessful attempt creates no repair entry.
  • The viewer honours shared-library contributor restrictions when saving title, tags, EXIF or rotation, and refuses writes after a file moves to another library. Storage-page writes also require library edit access.
  • Per-size downloads remain available in the media viewer, including custom and annotated renditions, without requiring media.manage.
  • The old address of a file no longer names a private library (its uuid) to someone who cannot read it.
  • The bucket page's "damaged copies" counts copies, not log entries: verifying the same copy twice counts it once.
  • The EXIF panel no longer shows two buttons and nothing else. A photo whose EXIF holds tags the panel does not print (an orientation, a lens maker) showed "All EXIF" and "Read again" under an empty heading. It now says "Nothing here about the camera, exposure, dates or location" and offers the whole dump.
  • A photo with tags but nothing the summary keeps (a screenshot with only ColorSpace and ExifVersion) is recorded as {"tags": N} rather than as having no EXIF, so "All EXIF" is offered; offsets to other data (ExifOffset, GPSInfo) are not counted and are left out of the dump. A photo with no EXIF at all stays {}.
  • "This photo carries no EXIF" has a "Read again" button where the host lets the reader write.

2.59.0 - 2026-10-09

Migration V215 (run mix phoenix_kit.update). It adds two nullable columns and a partial GiST index on phoenix_kit_files; no existing file changes, and no extension is needed.

Added

  • A photo's EXIF is kept, and its GPS position is searchable on a map (V215). The upload job records the camera, exposure, dates and GPS position in metadata["exif"] (Storage.Exif.summary/1; the camera's serial number and the maker note are not kept) and the position in phoenix_kit_files.latitude / longitude, indexed by a partial GiST index on point(longitude, latitude) (built-in Postgres, no PostGIS). Storage.list_files_in_scope/2 takes bounds: {south, west, north, east}, a box that crosses the antimeridian is searched as two (Storage.Geo), and Geo.parse_bounds/1 reads one from a URL or form. A photo uploaded before this is read on request: Storage.read_exif/1 (recorded only while the bytes read are still the file's original) and Storage.exif_tags/1 (the whole dump, read from the original, never stored). An edited image is read from its unedited backup.
  • A "Camera & location" panel on the admin media detail page and in the viewer's sidebar (Core.FileExifPanel): camera, exposure, dates and position, a "Show on a map" link, All EXIF and Read EXIF / Read again. The viewer offers it only where it offers the title editor, and the buttons that record also need a file the host may write.
  • Title, alt text and description in every language from one form (Core.FileDetailsFields): a tab per language, one Save, in the viewer's sidebar and on the detail page. The tabs switch in the browser, so what was typed in another language is kept; a tab with no text yet carries a dot. Storage.update_file_details_languages/3 writes all languages and the tags in one held write and leaves a language that did not change as it is, so another editor's later save of it survives. FileDetails.by_language/3 reads what a form posted.
  • A file's size in megapixels (Shape.megapixels/2) on the detail page.

Fixed

  • A tag a camera writes badly can no longer stop an upload or break the viewer. A number of hundreds of digits, a GPS date or time that is not a date or a time, and text in a legacy encoding (not UTF-8, which JSON cannot store) were read as-is; oversized numbers raised in the upload job and in "All EXIF", invalid timestamps were stored, and invalid text made the summary unwritable. Numbers, including exposure-time fractions, are bounded, a GPS timestamp must be a real calendar date and time, and bytes that are not valid UTF-8 are dropped where the tags are read (CaptureDate.parse_exif_properties/1).
  • Geo.parse_bounds/1 no longer raises on a coordinate of hundreds of digits (it reaches a URL).
  • Map bounds wrap correctly after panning several worlds west, including boxes crossing the antimeridian.
  • Saving a language form preserves newer edits to untouched fields. Both editors compare with the text the form initially showed; correcting a validation error also keeps the other changes awaiting save.
  • FileDetails.by_language/3 drops a value that is not text (a client can post a nested map), instead of putting it in the form to render.

i18n

  • New strings (language tabs, EXIF panel, dimensions) in all seven locales.

2.58.0 - 2026-10-08

Migrations V212, V213 and V214 (run mix phoenix_kit.update). The viewer's JavaScript libraries now come from the host's own origin: run mix phoenix_kit.update (or mix compile with the :phoenix_kit_js_sources compiler) so they are copied into priv/static/assets/vendor/lib/. Etcher's floor is now ~> 0.20.0.

Added

  • The viewer libraries are served from the host's own origin (PR #915). Fresco, Tessera, Etcher, Leaf, SortableJS, Panzoom and wavesurfer are copied into the host's priv/static/assets/vendor/lib/ as <name>-<version>-<hash>.js by the compiler (and by phoenix_kit.update / install), so a host with script-src 'self' keeps zoom, the editor, annotations, sortable lists and the waveform, and no viewer open makes a third-party request. phoenix_kit.js names no library URL: the file names come from install facts (window.PHOENIX_KIT_LIBS, written into phoenix_kit_modules.js). A CDN is tried only on the host's opt-in (config :phoenix_kit, library_cdn_fallback: true, read at compile time). Library URLs carry ?vsn= so Plug.Static caches them for good.
  • One library loader and an admin notice when a viewer library fails to load. Failures are classified from evidence (CSP violation with its directive, or a plain load failure) and shown to Owners and Admins, with the console line and a pk:library-failed event. mix phoenix_kit.doctor checks the compiler, the bundle and the install facts.
  • A file's aspect ratio and a Shape filter (V212). phoenix_kit_files.aspect_ratio is a generated column (width / height, Postgres 12+); Storage.Shape reads it (wide from 2:1, tall from 1:2) and list_files_in_scope/2 takes shape: :wide | :tall (pictures only). Media's Filter menu gets a Shape section, and the grid marks a panorama with a badge. Type, sort and shape live in the URL.
  • New sizes in the Default set (V213, V214). thumbnail_square and small_square (cropped around the subject), mini_square (64 px) and the panorama sizes thumbnail_wide, small_wide and medium_wide, made only for wide pictures. A size has a shape (any, wide or tall; the standard sizes stay for every picture) and the rendition form a "Made for" select. The migrations add them to installs with no files yet; a site with files is left alone, and "Reset to defaults" brings them. Grid cards use small_square and list rows thumbnail_square when a file has them.
  • The date formatters show the site time zone (PR #916). format_datetime_full_with_user_format/1, format_datetime_with_user_format/1, format_date_with_user_format/1, format_time_with_user_format/1, format_short_datetime/1 and the *_with_cached_settings variants move a DateTime (or a NaiveDateTime read as UTC) into the site's time zone, where they printed the stored UTC clock. A DateTime in another zone is brought to UTC first, so no unset zone ("0", "", nil) needs a time zone database.

Fixed

  • An upload into a second library no longer clones files from another library. The duplicate lookup matched any library placed alike; a donor must now be in the same library.
  • The Media toolbar menus show the chosen row (daisyUI 5 renamed active to menu-active; also on Activity and Users), and the Filter button names the type and shape in use.
  • A keyword CSP violation (inline, eval, empty) no longer reads as the cause of a library failure. Only a violation for an absolute URL is matched against a library.
  • The library notice no longer logs a missing-Gettext-bindings error on every admin render.
  • A cut-short library write is repaired. A vendored library is written to a temporary file and renamed, and rewritten when its size is wrong.
  • The wide/tall filter lists pictures only, as the panorama badge does; a wide video is not a panorama.
  • VariantSets.variant_for/2 and stand_in/3 skip a size made for another shape.
  • A URL with ?type[a]=b no longer crashes the Media page on the next navigation.
  • The date formatters read the time zone through the settings cache (one extra uncached read per formatted value in a list otherwise).
  • Stacks follow the Media toolbar's type, shape and sort. Folder preview counts, expanded stacks and their additional pages use the same options as the main listing.
  • An explicit null rendition shape returns a validation error, instead of raising on the database's NOT NULL constraint. A shape-only change is recorded in the storage history with its actor.
  • Relative PHOENIX_KIT_LIB_BASE values resolve against the page for every library, including wavesurfer's ES module, whose import() otherwise resolves against the bundle or rejects a bare path.

Changed

  • Etcher's floor is ~> 0.20.0 (it honours sortableUrl / loadSortableFromCdn = false); the lock moves to 0.20.0, phoenix_pubsub 2.4.1 and swoosh 1.29.0.
  • The CDN-pin tests (leaf_bundle_pin_test.exs, most of vendored_cdn_pins_test.exs) are gone with the CDN pins; the dependency ceilings in mix.exs are now a manual rule, and the comments say so.

Known limits

  • A 90°/270° rotation made in the viewer is stored in metadata["rotation"] only, so such a file keeps the shape of its unrotated size (EXIF orientation is handled).
  • Renditions of a size whose shape stopped fitting a file (after an edit or a shape change) are kept, not removed; the reconciler does not add the new ones until the set's revision moves.
  • phoenix_kit_sortable.js (the host-importable legacy loader) still loads SortableJS from jsDelivr.

i18n

  • New strings (library notice, Shape filter, panorama badge, "Made for") in all seven locales; the en catalog's two fuzzy flags are cleared.

2.57.1 - 2026-10-08

Fixed

  • Retrying a kept upload puts it where it was dropped. A failed upload from a private library could be retried from Media and landed there, losing the library's privacy (its files need a time-window URL). A kept upload now remembers its library and folder; the problems panel of a library lists only that library's uploads, and Retry stores at the recorded folder.
  • A failure to record a received upload no longer loses it. The bytes are put back where they came from before the inbox's copy is dropped, so the browser is only told to let go of its own copy once a server copy survives.
  • A refresh now brings the leftovers back. A record of the previous page load is looked at again once it has gone stale (before, the one scan 1.5 s after load saw it as fresh and nothing looked again), and the problems panel re-reads the inbox while an upload is in somebody's hands. An upload whose page has gone reads as interrupted at once instead of after two minutes.
  • Retry and Discard no longer touch an upload another tab is storing. Items are claimed by the page working on them; a stale panel's Retry or Discard is refused for an item a live page holds, whatever its age.
  • The browser's saved copies follow the library you picked them in, including after switching libraries or folders without a reload (the acknowledgement names the scope the file was picked under).
  • A refusal of a kept upload (another file type only) shows up in the panel at once.
  • Each browser on a page has an inbox item of its own for a received upload, so a failure in the second one stays findable after a refresh; a readonly browser lets go of its own item. Bytes with no record are swept after a week.
  • The HEIC subject finder's preview is removed even when the converter outlives the timeout.

Changed

  • The upload inbox directories are private (0700) and every user's inbox is swept at most once an hour when an upload arrives, not only when its owner opens the panel.

2.57.0 - 2026-10-08

Added

  • A module's section on the Emails Transactional page can put a button beside "Preview emails". An entry of email_settings_sections/0 may carry link: %{label: "..."}, and the Branding tab then shows a button that opens the section's tab (?tab=module_<id>); link: %{label: "...", path: "/admin/..."} leads to that path instead. The button is shown only to users who can see the section (the same permission).
  • The Media and Libraries page headers have a library switcher. The library name in the header is a breadcrumb switcher listing the viewer's own and shared libraries (and the site's), replacing the old <select>. Every row is a plain link to the library's address, so the page's own access check decides what opens. The switcher closes once the library has switched.
  • config :phoenix_kit, :upload_inbox_dir, "/path" moves the upload inbox (default: a directory under the system temp dir). See Fixed.
  • The media viewer shows a "Loading full quality…" pill while a sharper version of the picture is still on its way, including on the instant stand-in a step paints first.

Changed

  • The media viewer's neighbour warming is gentler, and no longer fetches originals. It used to download the small and large variants of both the previous and the next photo the moment the viewer opened, and, on a wide screen, both neighbours' multi-MB originals too, racing the sharper version of the photo being looked at. Now it waits until the current picture has settled, runs at low fetch priority, and does nothing on data-saver or a 2G/3G line. It warms the tiny small variant of both neighbours (so a step paints at once) but the large one only of the side being stepped towards (the next photo until you step back). Originals are no longer warmed by core at all: whether one is worth fetching is high-end viewing, which belongs to a module. Core announces the neighbours once settled (pk:viewer-neighbours: prev / next as {original, aspect}, direction, and the viewer box as {width, height, dpr}), and phoenix_kit_photos listens. The modal's data-neighbor-prefetch and data-neighbor-prefetch-hi attributes are replaced by one data-neighbors (JSON, per side: open, large, original, aspect). Without that module, stepping onto a large image on a big screen waits for its original instead of finding it cached.

Fixed

  • An iPhone's HEIC photo now gets its subject-aware crop and every rendition. Two things failed on a real .HEIC: a rendition with no format set ("Preserve original") tried to write HEIC, which ImageMagick can read but not encode, so it failed (no encode delegate for HEIC); and libvips as bundled by vix has no HEVC decoder, so the subject finder found nothing. A rendition that keeps the original format is now a JPEG (or the see-through format for a photo with transparency) when the original is HEIC, HEIF or AVIF. When libvips cannot decode a photo, the subject finder looks at a 512 px JPEG preview ImageMagick makes of it (ImageProcessor.preview_jpeg/3; it applies the EXIF orientation and keeps the usual limits). On a HEIC and its JPEG export of the same photo the stored point is the same. Photos already uploaded are fixed by Check every file on their rendition profile.
  • The External libraries tab checks that ImageMagick can read HEIC, with why it matters (iPhone and many Android photos), what happens without it (the photo is stored but no rendition can be made, every attempt fails) and how to enable it. It reads ImageMagick's format list, so it works on ImageMagick 6 and 7; only reading is needed, since a rendition of a HEIC photo is a JPEG. The "not found" notice names it only once a HEIC or HEIF photo has been uploaded.
  • An upload that did not land is no longer lost without a word. A file over the size limit sat as a progress bar stuck at 0% (and held one of the ten slots, so the next drop silently lost a file too), a failed store showed one anonymous count and deleted the bytes, a raise in the store took the page down with every queued file, and a refresh dropped the transfer. Now each such file is a named row in a problems panel with why ("Larger than the 500 MB upload limit", "Up to 10 files upload at once…") and Retry / Discard. Received bytes wait in a per-user inbox until they are stored and a failure keeps them; the browser stashes picked files (up to 250 MB each, per signed-in user) until the server confirms receipt, so a refresh or closed tab offers Resume. A Retry clicked in a browser that takes only another kind of file keeps the bytes instead of discarding them.
  • An email section's link path now goes through the shared redirect guard (Routes.local_path?/1), so a path with a control character ("/<tab>/host", which browsers read as //host) is refused too.
  • The subject finder no longer leaves a preview file behind when it times out on a large HEIC.
  • A kept upload's record is written atomically, so a list made while it is being updated can no longer take it for broken and drop it.
  • /admin/libraries switches to your own library when a shared one has the same slug.
  • HEIC detection ignores the extension's case (IMG_1.HEIC).
  • Stepping with the viewer's on-screen arrows warms the side being stepped towards, as the arrow keys do.
  • A malformed resume/retry/discard message from the browser no longer crashes the media browser.

2.56.1 - 2026-10-07

Fixed

  • Subject crops stay tied to the photo they were made from. Detection cannot record a point after an edit replaces the original, and a rendition cannot publish after its point changes. Editing clears the old point; reverting restores the unedited photo's point. Changing or clearing a point marks its generated subject crops for regeneration. Different focal points use different stored object keys, so copies of a photo with different manual points cannot overwrite each other's crops.
  • A failed subject detector falls back to the middle. Decoder exceptions are caught inside the linked task, and hosts without the optional Vix dependency compile without undefined-module warnings.
  • Video renditions preserve the supported settings. Height-fixed renditions and posters follow their height. Preserving the original container still applies quality, and a rendition shared by images and videos converts its 1–100 quality to CRF instead of passing invalid values such as 85. MP4 and MOV renditions explicitly select the H.264 encoder when setting CRF.
  • The new image regression fixtures work with ImageMagick 6 and 7, using the same convert command as the image processor. Added real FFmpeg output checks for video sizing and quality.

Upgrading

  • If you used subject crops in 2.56.0, run Check files on their rendition profile after upgrading. Their spec hash now changes, so the reconciler remakes them with separate keys for each point. Center crops and renditions that keep proportions retain their hashes.

2.56.0 - 2026-10-07

Added

  • A fixed image rendition can crop around the subject of the photo, not just its middle. The rendition form has a Crop choice for a fixed box ("Around the middle" as before, or "Around the subject"). The subject is found with libvips' attention model on a 512 px copy (the optional vix package, which bundles libvips: add {:vix, "~> 0.42"} to the host), stored with the photo (Storage.FocalPoint, in metadata["focal"], a person's choice never replaced by detection), and every cropped rendition of the photo uses the same point. Without vix, or for a photo with nothing that draws the eye, the crop stays at the middle. Orientation is respected. New column crop_mode (V210); no existing rendition is remade.

  • A rendition that keeps proportions can fix its height instead of its width, for horizontal panoramas and rows of photos: "Fixed side" in the rendition form, "240 px tall · width follows the image" in the table. A vertical panorama already worked with a fixed width. New column fit_by (V211); no existing rendition is remade, and the standard sizes keep fixing their width.

  • The External libraries tab lists libvips, with why it is there (finding the subject of a photo), how it is used (only for renditions cropped around the subject, once per photo), what happens without it (the crop stays at the middle) and, when it is missing, how to enable it (add vix to the app). It is found through the vix package, not the PATH, and the page's "not found" notice mentions it only once a rendition actually crops around the subject.

Fixed

  • A see-through image was not recognised on ImageMagick 7.1.1. identify now prints a channel count after the colour model (graya 3.0), which the transparency check read as "no alpha", so a PNG with transparency got a flat-background JPEG rendition. Only the model is read now.

Changed

  • A rendition set is now a "rendition profile", like a storage profile. Both are a named bundle a library picks once, when it is created, so both now say "profile": the tab is Rendition profiles (beside Storage profiles), the button is "New rendition profile", and the library form and page say "Rendition profile". Where a sentence said just "the profile", it now says "storage profile" or "rendition profile". The Renditions tab also opens its name form only after "New rendition profile" is pressed, like the Libraries and Storage profiles tabs, instead of showing it all the time. Code names (VariantSet, variant_set_uuid), URLs and stored data are unchanged; history entries written before this say "rendition set" for deep zoom's default.

  • The Renditions tab says what an image rendition's size means. The Dimensions column read just "300px"; it now says "300 px wide" with "height follows the image" under it, or "150 × 150 px" with "cropped to fit", the Mode badges read "Keeps proportions" and "Fixed size", and a legend under the image table adds that a rendition never enlarges an image. A video rendition shows its configured numbers without a claim about how they are applied.

Fixed

  • A video rendition now follows its own row. The generator ignored the configured size and quality of 360p, 720p, 1080p and video_thumbnail (they were fixed at 640x360, 1280x720, 1920x1080 and 640x360, at CRF 28, 25 and 23), and scaled any other video rendition to exactly its width and height, stretching a video of another shape, at a CRF wrongly mapped from the 1-100 image scale. Now a rendition that keeps proportions sets the width and the height follows the video, a fixed one is a box the video is scaled to fit inside (never cropped or stretched), neither enlarges a video, and the quality is the CRF you set (VP9 webm gets -b:v 0 beside it). A still-frame rendition (the video thumbnail) uses the 1-100 scale whatever it is called. The Renditions tab says what each mode does for video, with a legend.

  • Behind a proxy that writes the port, the visitor's address is now read. Caddy's {remote} (and any proxy like it) sends X-Forwarded-For: 203.0.113.7:28858 and [2001:db8::7]:28858; IpAddress could not parse that and fell back to the proxy's own address — one address for every visitor in rate limits, login attempts, the website-access lockout, sessions and the "new device" check. client_address/1, client_address_from_socket/1 and everything built on them now drop the port from a.b.c.d:port and [v6]:port (a bare IPv6 address is never cut: 2001:db8::1:443 is an address), and they and extract_ip_address/1 report an IPv4-mapped ::ffff:a.b.c.d as a.b.c.d. Which header is trusted is unchanged.

  • A session stored with the proxy's address moves to the visitor's once, quietly. Without this, every session signed in before the fix would read as "changed IP" on every request, and strict fingerprinting would sign them all out. A token whose stored address is loopback/private, used by the same browser from a public address through the proxy (the request's peer is loopback/private), is rebound to that address on its next request — no warning, no refusal — and checked as usual from then on. A user agent is easy to copy, so this is a one-off loosening of strict mode, not a check: what bounds it is that a session stored with a private/loopback address, used again through a private peer (a proxy) from a public address, moves once — a LAN client that signed in through the same proxy included. A request straight from a public peer, or from another browser, is not rebound.

Upgrading

  • ⚠️ Behind a proxy that writes the port, addresses change from the proxy's to the visitors'. WebsiteAccess.AllowedAddresses compares exactly: an allowed address written as the proxy's (172.18.0.8) stops matching — list the visitors' public addresses instead, before deploying. Existing sessions move to the visitor's address by themselves (above). The first sign-in of each user after the upgrade is a known browser on a new address, so it records a device and a user.new_login_detected activity entry, but sends no "new login" email or notification (that needs a browser the account has not used before). Failed website-access attempts counted against the proxy's address no longer count towards anyone's lockout.

  • ⚠️ On a dual-stack listener, IPv4 visitors change from ::ffff:a.b.c.d to a.b.c.d — with or without a proxy. An allowed address written as ::ffff:a.b.c.d must be written as a.b.c.d; each user's first sign-in records a new device row (no email, as above); and the website-access lockout counts exact addresses, so failed attempts recorded as ::ffff:a.b.c.d before the upgrade stop counting.

  • The standard video renditions' quality now applies. The seeded rows all say CRF 28, so on an existing install 720p and 1080p will be encoded at 28 from now on, where they had been at 25 and 23. To keep that quality, set them to 25 and 23 on the Renditions tab (new installs and "Reset to Defaults" are seeded that way). Videos already made are not remade by this change.

i18n

  • Translations for the video rendition wording, in all shipped locales.
  • Translations for the rendition profile wording, in all shipped locales.

2.55.1 - 2026-10-06

Fixed

  • Machine stamps no longer write permanent settings history (PR #908). A value a machine rewrites on a schedule — the jobs sweeper's job_runs_last_sweep_at (every five minutes), the sitemap's generation stats, its cached XML/HTML documents and its per-module stats — recorded a permanent setting.changed entry on every write, burying the Activity feed under rows the pruner never takes. These writers now pass history: false (cache invalidation and the settings change broadcast are unchanged). The option is refused with ArgumentError, before the write's transaction, together with actor_uuid: or source: "settings": what a person changes is always recorded. Entries written by earlier versions stay until removed.
  • Boot no longer re-runs one-shot steps under update_mode (PR #909). mix phoenix_kit.update and mix phoenix_kit.doctor start the host with update_mode on, where Settings.get_setting/1 answers nil, so "already done" flags read through it were ignored: the Admin auto-grant handed a custom permission key back after an Owner had revoked it, and the media sub-permission backfill re-ran. Both flags are now read from their row, and a flag that cannot be read grants nothing (fails closed, including a dead pool). Module migrate_legacy/0 callbacks no longer run in update_mode; the host's next ordinary start runs them.

Changed

  • Test: the sweep-stamp history test compares entry counts instead of asserting an empty history, so it passes on a database that already holds entries from before the fix.

2.55.0 - 2026-10-06

Added

  • The media viewer's eye is back, as a visibility toggle (PR #907). It only chooses which picture is on screen: from the burned view it swaps to the clean original, from the editor it ends the session onto the clean original (the burn is composed first), and un-hiding lands on the burned copy. Right-click → Copy image works on both finished pictures. Session-only: every open starts with the markup showing. Read-only live layers keep Etcher's own :visibility eye.
  • Open media ready to annotate — a per-user switch (profile settings → Media → Annotation tools) that opens the viewer with Etcher armed and the toolbar up, for images with annotation rights. Stored in the user's custom_fields (media_viewer_open_annotating), not cleared by "Reset annotation settings".
  • The text box tool in the viewer's toolbars (Etcher 0.19): a fixed box the text wraps and sizes inside, stored as a plain text shape with style.box: "fixed".

Changed

  • Etcher 0.19.0 (mix.exs pin and the CDN bundle). Same-tool editing: the shapes a tool draws stay grabbable while it is armed. The pin is a floor for the :textbox key.

  • Annotation tools moved from the profile's Account tab to its Media tab. The tab is offered to everyone; the libraries card inside keeps its storage gate.

  • Deleting a bucket asks Delete bucket "<name>"? instead of a long warning about when the delete would be refused; the refusal itself (in a storage profile, still holds files) is still explained after confirming.

  • The Buckets tab's stats count what they say. "Active Buckets / Currently in use" counted enabled buckets; it is now "Enabled" (with how many are disabled) and "In a storage profile" (with how many are in none and so store and serve nothing).

  • The Buckets list shows what each bucket holds. The Files column no longer repeats the word "files" in every row, and Objects and Size columns sit beside it, with the same numbers as the bucket page. They come from one batched query (Storage.bucket_totals/1) instead of one per bucket.

  • Priority is a storage-profile setting, not a bucket one. The Priority column is gone from the Buckets list, the bucket page and the bucket form. Where new files go is the profile's per-bucket upload order (Settings → Media → Storage profiles), which is now shown only for a kind of bucket with more writable buckets than copies to make; elsewhere the row says the order does not matter. A bucket no longer copies its priority into the Default profile when it is created or edited, so a new bucket joins with no fixed upload order (the shuffled pool). The priority column stays for the pre-profile selection and the read fallback order. The Buckets list is sorted by name. Docs no longer claim a bucket's priority prefers "the emptiest drive": the pool is shuffled, free space is not considered.

  • Each library has a page, and the Libraries list fits. A library's name opens /admin/settings/media/libraries/:id: overview and address, what it holds (files, folders, size, and per bucket where its objects are, with each bucket's type, service and location), its storage profile and variant set, the sync state with Check now / Pause / Resume, its annotated-thumbnail choice, its history, and Rename and Delete. The list is down to name, files, size, storage (as text) and sync; the three dropdowns, Address, Folders, Rename and Delete are gone from it.

  • A library's storage profile and variant set are chosen when it is created and no longer changed from the admin. The New library form offers them when there is more than the Default of each; the library page shows them read-only. Re-pointing a library was a bulk move of its files behind a dropdown. To move where files live, change the profile's buckets (Storage profiles tab), which moves every library on it. The context functions Profiles.set_library_profile/3 and VariantSets.set_library_variant_set/3 still work from code. Libraries.create_system_library/2 takes storage_profile_uuid and variant_set_uuid; an unknown profile or a user's own is refused. Deleting a profile a library is on now says the library keeps its profile.

  • A library's annotated-thumbnail choice moved to its page, with a note that existing thumbnails are not regenerated when it changes.

  • Dimensions are now "Renditions", a tab of Settings → Media. The sidebar's Media › Dimensions and Media › Health items are gone: Renditions (after Storage profiles) and Health (after Libraries) are tabs, like Buckets and Libraries. A rendition is a smaller or re-encoded copy of an upload (a thumbnail, a 720p video); the old words "variant set", "dimension", "size" and "preset" in the admin are now "rendition set" and "rendition", and the add and edit pages for one are /admin/settings/media/renditions/…. The old /admin/settings/media/dimensions and /health addresses redirect to their tabs. The Health report is read when its tab opens, not when the page loads.

  • Deep zoom (zoomable tiles) moved from the rendition set to the library. A library's page has a Viewing card with a Deep zoom switch beside Annotated thumbnails. A library that has not chosen keeps following its set's old flag, so nothing turns on or off; the checkbox is gone from the set. Turning it off only stops serving tiles, the ones made stay stored. The ImageMagick notice now asks whether any library has it on.

  • The library page links its rendition set to its tab, and PhoenixKitWeb.Live.Modules.Storage.LibraryPage is now in the permission map (media.manage) with the other storage pages.

Fixed

  • Reset annotation settings drew retired pastels. The viewer's copy of Etcher's old palette is gone; with nothing saved the viewer hands Etcher no colours and Etcher seeds its own current presets.

  • A burn taken mid-edit rendered the label editor's input box into the stored copy (.etcher-text-editor is now burn chrome).

  • Library creation picks up storage profiles and rendition sets made in another tab. Opening the Libraries tab and its New library form refreshes the choices; the library list uses the loaded names instead of reading each profile and set again while rendering.

  • The Renditions tab refreshes when reopened and survives stale row actions. Deleted or malformed rendition ids, and ids from another set, are refused without crashing the page. A set deleted elsewhere falls back to the Default when the tab is reopened or its old form is submitted.

  • Library-setting history names the inherited default. Annotated thumbnails retain "site default"; deep zoom says "rendition set" when the library has no override.

  • The Health report keeps personal-library filenames private. Their files still contribute to the totals and reconciliation work, but only site-library files appear in the detailed list. A report waiting only on personal files no longer says "All Healthy".

i18n

  • Translations for the open-annotating switch, in all seven non-English catalogs.
  • Translations for the renditions wording, the Viewing card and the two tabs.
  • Translations for the new bucket delete confirmation and stats captions, in all shipped locales.
  • Translations for the library page, the creation-time storage choice and the reworded profile refusal.

2.54.2 - 2026-10-05

Fixed

  • An open row menu in a popup no longer goes dark when the popup is patched. A LiveView patch covering the dialog moved the open menu back into its row, hidden, while the hook still counted it open, so the next click on the ⋮ did nothing. The hook now notices and puts the menu back in the dialog, and a modal dialog whose open attribute a patch has momentarily stripped still counts as the portal.

2.54.1 - 2026-10-05

Fixed

  • A row menu could stop opening after its row was patched. RowMenu (2.54.0) set popover="manual" on the menu once at mount; a LiveView patch of the row while the menu was closed stripped it, and the next open threw from showPopover() and left the menu unpositioned on screen. The attribute is now applied on every open.

2.54.0 - 2026-10-05

Upgrading

  • mix phoenix_kit.update now schedules three workers that older hosts never ran, and the first run of Activity.PruneWorker deletes activity entries older than activity_retention_days (default 90) from every host that had never pruned. Raise the setting before updating to keep more history, or comment the entry out of the crontab to decline it (the updater leaves a commented-out entry alone).

Added

  • <.table_default fit> drops the least important columns instead of scrolling sideways. Header cells declare priority (the highest number goes first, equal priorities from the right; none never goes), lead (the one column that stays left and takes the slack) and width; the new TableFit hook measures the real table against its wrapper on mount, on resize and after every patch, and hides whole columns through one stylesheet, so stream inserts need nothing. The last visible header cell shows +N while columns are dropped, the remembered result is put back before first paint on a hard load, and printing shows every column. fit_pack={false} keeps the dropping and leaves the sizing to a table that sets its own column widths. The Integrations table uses it (Account goes first, then Name, then Status). Guide: dev_docs/guides/2026-09-11-core-components.md.
  • Every email handed to an adapter leaves a log line: the subject, the adapter (or the integration) and the recipient with the local part masked (m***@don.ee), at info when it was sent and at error, with the reason, when it was not. Addresses echoed in adapter errors are masked before truncation, including nested responses, SMTP charlists, exception structs and improper lists, and building the line can never change what deliver returns. Never the body, which carries single-use tokens. Without the optional emails package a send used to leave no trace at all, and a failed one returned an error to callers that show the same page either way.

Fixed

  • Cron backfills resolve worker aliases and inspect actual scheduled entries. An aliased digest worker (for example alias PhoenixKit.Notifications.DigestWorker, as: D) no longer makes the duplicate guard refuse every missing cadence. A worker mentioned in another job's arguments, or a longer module name that begins with the worker's name, no longer suppresses its own cron entry.
  • The table-fit +N indicator remains visible when the final columns are dropped. It is attached to the last surviving header instead of a hidden one.
  • Two crontab entries were never backfilled to existing hosts, and a third was in no crontab at all. Modules.Storage.Workers.PruneTrashJob and Notifications.PruneWorker were in the generated crontab but not in the update's backfill list, so a host that installed before them never ran them. PruneTrashJob is more than the trash purge: it is what queues the storage backfills and the reconciler, so such a host never reconciled its storage profiles. Activity.PruneWorker was documented as running daily but was in no crontab (not the generated one, not the backfill), so no host ever pruned its activity feed. All three are now in the generated crontab, the manual-instructions text (which also lacked the job-run sweeper and prune and the bucket-log prune) and the backfill. A test now fails when a worker is in the generated crontab but not in the backfill, when the two crontab texts disagree, or when a worker's docs say it runs on a schedule and no crontab has it.
  • mix phoenix_kit.update no longer skips cron entries when the host's crontab ends in a comment. The Oban config backfill glued , onto the tail of the source text, so a comment after the last tuple took the comma, the edit stopped parsing and was rolled back, and the host was left without Jobs.SweepWorker (stuck job runs never recovered), Jobs.PruneWorker, BucketLogPruneWorker and LoginAttemptsPruneWorker. Every list splice (worker, digest and scheduled-job entries, the Cron plugin, Lifeline, queues) now goes through the new PhoenixKit.Install.ConfigSplice, which finds the end of the list with comments and string bodies masked and appends after the last real element. A splice that would change anything else the host wrote, or add a duplicate plugin, crontab entry or queue (Oban 2.24 names and alias resolved), is refused and leaves the file untouched.
  • The update ends with one "Manual steps needed" block listing every config edit it could not make (also when the run ends in a failed migration), and the success line says N manual step(s) below instead of claiming a clean update.
  • A second log-out no longer answers 403. The log-out button submits the CSRF token of the session it was rendered in; once that session is gone (a double click, a second tab) the host's protect_from_forgery refused an action that had in fact succeeded. PhoenixKitWeb.Plugs.AlreadyLoggedOut, ahead of :browser on the log-out scopes, exempts such a request from the CSRF check only when there is no session token, no account stack and no remember-me cookie, so it takes the ordinary path: the same flash and the same locale-aware redirect as a first log-out. Wired by PhoenixKitWeb.Integration; a host adds nothing.
  • The redirect from a disabled module's page lands on the Disabled tab (/admin/modules?tab=disabled), where the module's card and its switch are. It went to the Active tab, which does not list the module at all, so the page read like a missing route. The Modules page honours ?tab=active|disabled|not_installed.
  • Setting labels on the Settings tabs no longer overlap. A long (translated) description widened the first column over the second; labels now wrap and every fieldset track is minmax(0, 1fr). The mention checkboxes use the checkbox's own description slot.

Changed

  • Declining a cron entry means commenting it out inside the Cron plugin's crontab:. The updater skips it, says so, and lists it in the closing summary. Before, a comment naming the worker anywhere in the file also suppressed the entry; a host that declined an entry that way must move the comment into its crontab list.
  • Provider icons sit in a tile of fixed size on the Integrations page, so glyphs that fill their box differently (an envelope against a sparkle) line up.

2.53.0 - 2026-10-04

Upgrading

  • Run mix phoenix_kit.update: it applies V209 (copies_local / copies_cloud on phoenix_kit_storage_profiles). Existing profiles keep their total and are split by the buckets they have, local first, so no file is placed again. If the local buckets cover the old total, the backfill sets Cloud copies to zero, even when a cloud bucket is listed. Set it above zero to write to the cloud; otherwise the backfill assigns the copies the local buckets cannot take to available cloud buckets.

Added

  • A storage profile counts its copies per kind of bucket: local and cloud (V209). "Copies of each file" is now Local copies (on the server's own disks) and Cloud copies (S3, B2, R2, Tigris), so a profile can keep two copies on disk and one in the cloud, which survives the server. Cloud copies can be set only for a profile that has a cloud bucket; a kind the profile wants no copy of is not written, whatever buckets it has. If a bucket is taken out of a profile, the reconciler finds the files that fall below the count and copies them to another bucket of the same kind (the Health page shows what is left). V209 adds copies_local and copies_cloud to phoenix_kit_storage_profiles and splits every existing profile's count by the buckets it has, local first; no file is placed again. copies_originals stays as the total (a CHECK keeps it equal to copies_local + copies_cloud) and copies_variants is no longer used (both follow the counts). A personal backup library's profile gets one cloud copy for the backup and one copy on each site bucket, so its thumbnails and tiles now follow the originals onto every site bucket and the user's backup instead of one site bucket.

Changed

  • A bucket in a storage profile holds everything the profile sends it. The choice of storing only originals or only sizes and tiles (the Stores column) is gone, and so is its effect: placement, the reconciler and the copy advice no longer read phoenix_kit_storage_profile_buckets.stores, so a row that had it set is treated as storing everything. The column stays in the table, unused.
  • The copy hints speak of "files" and of local and cloud buckets, and the "Keep every original on N buckets" button is gone: set Local copies or Cloud copies. A new bucket's confirmation says when its profile keeps no copy of its kind yet.
  • The storage profiles bucket table's headings are in sentence case, like the Libraries and Buckets tables (Bucket, Role, Upload order, Serve order, Status). 2.52.2 had made them capitals; the rest of Storage settings does not use them.

Fixed

  • The reconciler checks local and cloud counts separately and verifies the copies it keeps before deleting another copy, including a real serving copy. Extra local copies can no longer hide a failed or missing cloud copy and mark a file as fully placed. A kind set to zero is no longer written for a serving fallback; its old copies are removed only after the wanted kinds have enough copies.
  • Cloud copies can be lowered after the last writable cloud bucket is removed, and the upload minimum can be increased in the same save as the copy counts. The form no longer blocks these edits.

2.52.2 - 2026-10-04

Added

  • The local bucket form shows where the storage path lands on disk. Under the Storage Path input a line shows the absolute path the files go to as you type, and says when the folder does not exist yet (you are asked to create it on save), is a file, or cannot be written to. The hint no longer asks for an absolute path: a relative one (priv/media, the seeded default) is accepted and resolved from the directory the application started in.
  • A bucket that several storage profiles list says so (Settings → Media → Storage profiles). A badge under the bucket's name in each profile names the other profiles that use it, the Add bucket picker marks such buckets "also in …" (free ones first) with a note, and adding one confirms that it is now shared: its on/off switch, size limit and keys belong to the bucket, so they apply to every profile that lists it.

Changed

  • The storage profiles bucket table fits a normal screen. The columns are narrower (about 60rem instead of 70), and Save and remove share one column that stays in view when the table still scrolls, so the remove button is never scrolled out of sight. The headings that carry a tooltip keep the header's capitals like the rest.

Fixed

  • Every form on the Storage profiles and Libraries tabs showed "Unsaved changes" after a reconnect. LiveView replays each form's values to its phx-change event when the socket reconnects (a tablet that slept, a dropped network), which marked every row dirty. Those forms now recover through an event that does nothing (phx-auto-recover); SaveButton's docs say a form using it must do the same.
  • Saving a local bucket whose path is a file, or a folder the application cannot write to, crashed the form on an unmatched case clause; it now shows an error and stays on the form.
  • The local path's existence check and the "Create path" prompt expanded a relative path from the working directory, while files are written under the directory the application started in (they differ in development, where the code reloader changes the working directory). Both now go through Local.resolve_path/1, as Local.root/1 does. Storage.local_path_status/1 is new.
  • The doctor no longer tells a host to delete a duplicate sitemap route that leads to the same controller and action (#904). Such a repeat is usually deliberate (a /sitemap.xml declared ahead of scope "/:locale" so the locale segment cannot capture it) and removing it breaks the route. A repeat that leads elsewhere is still reported, now naming the action when both sides share a controller.

i18n

  • Twelve new strings in ru, de, fr, es, it, pl and et; the local path hint was reworded. 0 fuzzy entries.

2.52.1 - 2026-10-04

Fixed

  • mix phoenix_kit.update no longer reports success when its Oban schema migration did not take effect. After the staged phoenix_kit_update_oban_vNN_to_vMM migration ran, a schema that was still behind, unversioned or missing only printed an error and the update carried on; it now raises, stops the module migrations that follow, and names the target and the migration path to check.
  • A comment in PhoenixKit.ObanSchema named the wrong Oban version: the Postgres engine's current_version/0 already exists in Oban 2.20, not 2.21.

Known limitations

  • The welcome email is enqueued inside the confirmation's transaction only when Oban runs on the same repo as PhoenixKit. With Oban on a separate repo, a confirmation that rolls back leaves its welcome job committed, and the email can still be sent.

2.52.0 - 2026-10-04

Added

  • Core's emails are written in Markdown, with a button (#899). The eight auth emails (confirmation, password reset, magic link, login alert and the rest) have Markdown defaults with a call-to-action button and the address written out under it, translated into all eight core languages. Markdown email bodies gained optional paragraphs (a top-level paragraph whose placeholders all fill in blank is left out of both bodies), raw-HTML blocks written as a lone triple-brace placeholder, and links whose label is their own address (printed once in the text body).
  • A welcome email, off by default (email_welcome_enabled, Settings → Emails → Sending). It is sent at most once, after an address is confirmed — by the confirmation link, a magic link, OAuth or magic-link registration. The confirmation only enqueues WelcomeEmailWorker inside its own transaction, so a rollback takes the job with it; a refused or locked insert is logged and never fails the confirmation.
  • The notification email goes through the shared layout — the email notification channel sends core's notification email, so it carries the header, footer and a host's overrides like every other email.
  • A core-owned sidebar order for module tabs, and config :phoenix_kit, :admin_tab_order (#901). Core now places each module's tab itself instead of leaving the order to load order, with id tie-breaks. A host moves tabs, or whole groups, between groups and positions with :admin_tab_order (see lib/phoenix_kit/dashboard/ADMIN_README.md); unknown groups are ignored with a warning.
  • mix phoenix_kit.update steps Oban's schema up to the Oban library (#902). Oban's schema is versioned apart from core's chain, and core's open ~> 2.20 pin lets a host move to a newer Oban that expects a newer schema — under Oban 2.24 (schema v14) every unique insert, cron's included, failed with invalid input value for enum oban_job_state: "suspended". The update checks the schema at core's prefix and at the host Oban config's prefix and writes a host migration (phoenix_kit_update_oban_vNN_to_vMM) that runs Oban.Migration.up/1; mix phoenix_kit.doctor and status report the version, and the application logs one warning at boot when it is behind.

Changed

  • The HTML body of an email is built from the highest-priority host part first. A host that overrides only text.txt of an email now gets its own words in the HTML version too, instead of an HTML version built from the caller's html or markdown default. The text body order is unchanged.
  • The email-confirmation waiting screen is shorter (#900): one line with the address and when it was sent. With no confirmation on record it says so instead of claiming an email went out.

Fixed

  • The admin Modules page no longer rescans every beam file on each mount (#895). ModuleDiscovery.cached_external_modules/0 keeps the scan until ModuleRegistry rescans or a module is registered/unregistered; concurrent cold readers share one scan (node-local lock). Compile-time callers still read the disk.
  • The dev server no longer rescans beam files on every request (#898). The router's __mix_recompile__?/0 uses ModuleDiscovery.module_hash_fast/0, which fingerprints the code path with stat only and runs the full scan when the fingerprint changed. A merge dropped the module attributes behind its memo, which made every call fall back to a full scan; they are restored, and a fall-back is now logged.
  • The schema manifest no longer carries the newsletters template foreign key (#896); the newsletters module owns that table's shape.
  • The Oban schema check works on Oban 2.20–2.21, where Oban.Migration.current_version/1 is not public yet, and follows the {repo, opts} form of the Oban :repo option.
  • The welcome email enqueue catches an exit as well as a raise, so a dead pool on a magic-link registration cannot fail the response.

2.51.0 - 2026-10-04

Added

  • A page per bucket (Settings → Media → Buckets → the bucket's name, /admin/settings/media/buckets/:id). Edit stays the form; the page links to it and offers Enable/Disable, Delete and Add to profile, all through the guarded context calls (a bucket in use is refused with the usual message). Five sections:

    • Overview — location, service, the Integrations connection (linked for a holder of integrations_system), the "Keys on bucket" badge, access type, priority, maximum size, CDN, created and updated. The type/service/location wording now lives in Storage.BucketInfo, shared with the list.
    • Used by — each site profile with role, status and the libraries behind it; a user's profile and libraries are counted, never named.
    • Contents — files, objects, bytes, originals apart from derived, a capacity bar against max_size_mb (the disk's free space for a local bucket) and a per-library breakdown (user libraries aggregated). New Storage.bucket_contents/1; loaded with start_async, never in mount/3.
    • Health — a Test connection button (never run on open: it writes, reads and deletes a real object) over the new Storage.probe_bucket/1, which probes the saved bucket without passing a key through the assigns; location rows by status (Storage.bucket_location_health/1); the files still on a draining bucket.
    • History — every change to the bucket and to its rows in profiles, kept permanently, following live. A user's own bucket (V206) does not open here. The Buckets list links each name to its page. Gated by media.manage like the other storage screens.
  • V208: the bucket log, and a Log section on the bucket page. phoenix_kit_bucket_log (no foreign key, so it never blocks deleting a bucket; Storage.delete_bucket/2 removes the bucket's rows) records, for a site bucket only (a user's own bucket and an unsaved form test are never logged):

    • every write, read or delete that failed (Manager reports it in the background through a dedicated supervisor capped at four tasks, skipping diagnostics when unavailable or full), with a controlled diagnostic that excludes response bodies, headers, URLs and credentials; a missing object on read or delete is how a failover starts and is not logged (failed writes are), and a success is not logged either — a row per request would swamp a busy site;
    • every probe of a saved bucket (Storage.probe_bucket/1) with its result and how long it took, so latency over time comes from the probes;
    • the same failure within a minute of its first occurrence is one row with a count, so a bucket that is down does not write a row per request. The page shows the failures of the last day, the last failure, a chart of the latest probe times, and the entries (all, or failures only). The last probe's result now outlives a reload. A host that has not run the update yet gets a notice instead of an error. Entries are pruned daily to bucket_log_retention_days (default 30) by Storage.Workers.BucketLogPruneWorker; mix phoenix_kit.update adds its cron entry (20 4 * * *) to existing hosts. The manifest declares the table, its columns, constraints and indexes.

Fixed

  • Bucket page review findings. Shared keys count once in bucket object/byte totals while every logical file and library remains counted. Failure logging has no synchronous fallback into file transactions, and log messages and displayed probe errors contain only controlled diagnostics. Continuous failures start a new row after a minute; probes sharing a timestamp have a deterministic order. Navigating to another bucket resets its probe and contents, and legacy credentials stay out of page assigns while bucket actions reload the stored row before changing it.

i18n

  • New strings translated in all seven locales, 0 fuzzy.

2.50.0 - 2026-10-03

Changed

  • Languages is core and always on; the Modules page toggle becomes a switch on its own settings page. Languages was a feature module with an Enabled/Disabled card on Modules, but it is bundled, nothing installs or removes it, and what the toggle really controlled was whether the site is multi-language. It now follows Jobs: languages is a core permission key (Permissions.core_section_keys/0, so feature_enabled?("languages") is always true), its Settings → Languages tab is a core admin tab, and PhoenixKit.Modules.Languages is no longer a PhoenixKit.Module or in ModuleRegistry. The first card of Settings → Languages is Multiple languages, the same languages_enabled setting (Languages.enabled?/0, enable_system/0, disable_system/0), so an existing install keeps its state, its role grants (the key is unchanged) and its configured languages. Off, the site serves its configured fallback locale only and the page shows just the switch; on, the configuration, URL behaviour and switcher preview appear. The page used to be refused by Auth while the module was off, so the switch could only be reached from Modules; the core key is what lets it live on the page.

    • Language configuration APIs keep their behavior; the former PhoenixKit.Module callbacks are removed. get_languages/0 now backs the settings page (empty while off) instead of the preview list, which no admin screen shows any more.
    • Full operator-access checks now require languages even while the site's multi-language switch is off. Custom roles keep their existing grants; no additional authority is backfilled.
    • migrate_legacy/0 (the one-time copy of publishing_default_language_no_prefix) was run by the registry's boot sweep; PhoenixKit.Supervisor now calls it next to normalize_language_settings/0.
    • Packages that call Languages.enabled?/0 or get_enabled_languages/0 are unaffected. Anything that looked the module up as ModuleRegistry.get_by_key("languages") now gets nil.
  • The new-bucket form asks which storage profile the bucket joins, and offers "None" first. Before, every bucket created in Settings → Media silently joined the Default profile, so it was written to by every library that names no profile of its own. With none chosen the bucket is only added to the system: it receives and serves nothing until a profile lists it (Storage profiles tab). A chosen profile takes it as a primary and the profile's files are placed again. Storage.create_bucket/2 takes profile: (nil, a site profile's uuid, or :default, which is what it did and still does when no option is given, so existing callers are unchanged); Profiles.add_bucket/3 is the general form of add_to_default/1.

Fixed

  • Language settings and navigation review fixes. Locale switching preserves an unrelated host segment such as /nl/products when Dutch is not enabled; an explicit current locale still strips a language that has since been disabled. An empty frontend dropdown no longer renders a globe menu. The Languages page's preview controls now update both its generated code and live preview, restoring saved languages no longer claims English is the default, and configuration events from an already-open page are refused after another admin turns multiple languages off.

  • A language menu offered languages the site does not serve when the Languages module was off. The admin user menu and the frontend Core.LanguageSwitcher read Languages.get_display_languages/0, which returns a dozen hardcoded defaults while the module is off (it feeds the admin Languages page's preview), so a host with the module disabled showed ja, es, fr and the rest, each linking to a locale route that does not exist. Both now read Languages.get_enabled_languages/0, which is empty while the module is off, and the continent grouping (get_enabled_languages_by_continent/0) follows. Two lesser corrections ride along: a language switched off inside an enabled module is no longer offered by the frontend switcher, and the admin user menu hides its language section when there is only one language, as the dashboard menu already did. get_display_languages/0 keeps its behaviour and now says it is not a list to offer a visitor.

i18n

  • The Multiple languages card and its flash messages in all seven locales; the three Modules-card strings that nothing uses any more are gone.
  • The new-bucket form's storage profile strings in all seven locales.

2.49.1 - 2026-10-03

Added

  • The Buckets list shows which storage profiles use each bucket. A "Used by" column names the profiles (with the bucket's role and status there, and how many libraries stand behind each) or says "Not used"; a user's personal profile is counted, never named. The bucket's edit page carries the same notice. (Profiles.bucket_usage/1, Profiles.library_names_using/1.)

Changed

  • A bucket a storage profile lists cannot be deleted or disabled. Storage.delete_bucket/2 and Storage.update_bucket/3 (turning enabled off) return {:error, {:in_use, usage}} while any profile has a row for the bucket, whatever the row's role or status (a user's own profile counts too), and the settings page and the bucket form say which profiles. Free the bucket first on the Storage profiles tab, then delete or disable it. Enabling is always allowed, and a user's own bucket is unchanged. Deleting an empty bucket no longer takes it out of every profile on the way — that could leave a library with nowhere to write. The delete confirmation no longer says it removes location records (it has refused a bucket holding files since V204).
  • The refusal to delete a storage profile that libraries use names the site libraries in the way (a user's library is only counted), and tells the Default apart from a profile in use.

Removed

  • Profiles.remove_bucket_everywhere/1 (only the bucket delete called it).

i18n

  • The new strings in all seven locales.

2.49.0 - 2026-10-03

Added

  • Each library's storage has a state, and can be checked and paused. Settings → Media → Libraries has a Sync column: Up to date, Syncing ("N files left"), Paused, Waiting (files are out of date and no run is working on them — the case the Health page's global count hid) or Needs attention (the last run failed with files left, or the reconciler keeps failing on some). Check now, Pause and Resume are offered to those who hold jobs.manage and media.manage, and the column follows the library's run live. "Up to date" means the files carry their library's current profile and variant-set revisions; it does not prove every object is still on its bucket. The Health page links to the storage runs. (Storage.LibraryState; Reconciler.out_of_date_query/1 and counts_by_library/0 count what is out of date apart from what the reconciler may take now.)
  • Settings → Media has a History tab, and every change to the site's storage settings is recorded. Who created, renamed or deleted a bucket, a storage profile, a library, a variant set or a size; who added a bucket to a profile, changed its role, order or status, or took it out; who moved a library to another profile or variant set or changed its annotated-thumbnail choice; who reset the sizes or remade them all — each with the person, the time and what changed from → to (Storage.Audit, storage.profile.updated, storage.library.profile_changed, …). The entries are permanent (not pruned by the activity retention), name only fields that are not secret — never a key — and leave out a user's own library, profile and bucket, which are private. The tab lists them with the storage job runs' entries, filters between the two, pages, and follows the log live; each row opens its full entry on the Activity page. The context functions take actor_uuid: in their options (create_profile/2, put_bucket/4, set_library_profile/3, Storage.update_bucket/3, …); without one an entry says the system did it.
  • Each History row can be opened in place ("Show every field"), so a media manager who may not open the Activity page (media.manage does not imply dashboard) still reads every field that changed, not only the first three. Run titles never contain a user's library name: a user's library is "a user's library" on the Jobs page, the Libraries tab and in the History.
  • PhoenixKitWeb.Components.Core.ActivityList: a list of activity entries for any screen that shows a slice of the log; the Activity page shares its summarize_details/1.
  • The Runs tab warns when a queue is not working: a run's batch has been available for ten minutes and nothing in that queue has run since — observed from Oban's own table, so a web node with queues: false does not cry wolf for a worker node.
  • Jobs.can_start?/2, for a screen that offers a button that starts a run.

Changed

  • The storage reconciler is a job run per library (storage.reconcile) instead of one walk over every file. A change to a profile, a variant set or a library's choice still queues Workers.ReconcileJob, which now starts the runs for the libraries that have files to bring up to date (a pass already running begins again at its next batch). Each run shows on Admin → Jobs with its progress, can be paused between batches, and keeps a history.
  • The location backfill, the checksum backfill and the library purge are job runs too (storage.location_backfill, storage.checksum_backfill, storage.purge_library). The purge is visible but cannot be paused or cancelled: it walks the whole library and cannot be undone. The old Oban jobs remain as shims that start the runs, so jobs queued by 2.48.0 or earlier still work.

Fixed

  • Storage configuration changes and their audit entries commit together; failed audit inserts roll back the change, and rolled-back changes produce no storage audit announcement. Diffs use locked, current rows, so stale forms cannot record incorrect old values or duplicate no-ops.
  • Storage audit redacts credentials and query tokens in endpoint/CDN URLs and includes bucket access, capacity and CDN changes, alternative size formats, and checks requested for one set.
  • Media History identifies the affected resource, includes global storage settings with the signed-in actor, refreshes after missed announcements, and handles invalid or removed pages. Historical users retain their UUID label; Activity links require dashboard access.
  • Object Storage service labels load their setup module before checking its optional label callback, so the Integrations list shows the service on its first visit after boot.
  • Storage reconcile triggers preserve restart requests for active libraries even when their files are temporarily ineligible, and return start failures so the trigger job can retry.
  • The stalled-queue warning ignores paused runs and superseded dispatches, and matches the current dispatch using each table's configured schema.
  • The Libraries tab refreshes derived sync state every 30 seconds while visible, including changes that produce no run event. Its counts query reads only the displayed libraries.

i18n

  • The new strings in all seven locales.

2.48.0 - 2026-10-03

Added

  • PhoenixKit.Email.Layout.render_parts/2: the site's email header and footer on their own, for a module that builds its own document (#893). Chosen by the same code as the layout (group, then shared, then core; the reader's language; a blank file counts as missing), and without :paths it reads the host's template directories like every email. See the email templates guide.
  • Job runs: long background work an admin can watch and control (V207, PhoenixKit.Jobs). A run is a durable record of one piece of work — its state, progress, who started, paused or cancelled it, and its history — that Oban executes batch by batch. Jobs.start/pause/resume/cancel/retry take the signed-in Scope and check jobs.manage and the kind's own permission against the active role; boot, cron and scripts use Jobs.System.start/3, and Jobs.run_inline/3 runs the same kind in a script without Oban. A module adds a kind with use PhoenixKit.Jobs.Kind and job_kinds/0 (or config :phoenix_kit, job_kinds: [...]); the engine owns the rest.
    • Every transition is one transaction: the row is locked, the change written, the next batch's Oban job inserted and the Activity entry made together, so a crash cannot leave a run with no job or a change with no history. A batch holds a claim on the run; an Oban job carries the generation it was made for and an older one does nothing. Pause and cancel while a batch executes are requests (pausing, cancelling) the batch settles at its checkpoint; resume is refused while one drains; a trigger that arrives during the last batch starts a fresh pass instead of being lost.
    • A sweeper (Jobs.SweepWorker, every five minutes) releases a dead batch's claim, fails a run whose Oban job was discarded or cancelled, and rescues a lost dispatch up to three times; Jobs.PruneWorker deletes finished runs after job_runs_retention_days (default 90). mix phoenix_kit.update adds both to the host's crontab.
  • The Jobs page has a Runs tab (the default), with state and module filters, progress bars, the controls each viewer may use, a drawer of one run with its history, and live updates; Oban's own table is the Queue tab. It warns when runs are waiting and the sweeper has not been seen for 15 minutes. The tab, filters and opened run are in the URL.
  • The capture-date backfill is the first kind (storage.capture_date_backfill, media.manage): it shows on the Runs tab with its progress and can be paused, resumed and cancelled. The mix task runs the same kind inline.

Fixed

  • A feature module's email text came out in the sender's language, not the recipient's (#892). RecipientLocale.in_locale/2 now also installs the recipient's base language as the process locale for the duration, which is what a module's own Gettext backend reads.
  • Recovery of job runs is decided under the run's row lock. The sweeper lists candidates; the engine (Engine.recover/4) reloads each and decides from the run as it is then, so a slow sweep can no longer undo a newer dispatch, steal a live claim or charge the rescue budget twice. A rescue or an outside failure of a run a batch holds is refused by the state machine.
  • A script's claim (Jobs.run_inline/3) is a lease the sweeper respects (new column claim_owner, an hour, renewed by Jobs.heartbeat/1); a dead script's run is taken back when the lease runs out. Inline execution now waits out {:snooze, s} and schedule_in:.
  • An obsolete Oban delivery stays inert even when its kind has gone: the worker claims first and only then looks the kind up.
  • Job runs work on a named Oban schema: recovery, Jobs.get_job_stats/0 and the Jobs page read Oban's table through its configured prefix, and check a job is the run's own.
  • The engine refuses to run inside a caller's transaction ({:error, :in_transaction}), so no event or on_finish/2 escapes a rollback; Jobs.start/4 documents {:error, :raced} as "not recorded".
  • A script that asks its next batch to wait keeps its run through the wait (wake_at, and the script stays the run's owner between batches): the sweeper no longer reads the wait as a lost dispatch, and a rescue carries what is left of a delay into its dispatch.
  • A script's wait belongs to the script that asked for it: Jobs.run_inline/3 carries an owner identity (owner_token) between its batches, another invocation is refused while it stands, and a rescue keeps a delayed run's due time.
  • Without an Oban instance the sweeper, the stats and the Jobs page no longer guess: a run that never had a dispatch keeps its rescue budget, nothing falls back to the default schema's table, and the Queue tab says Oban is not running on that node.
  • The "counts may be approximate" notice follows interruptions, a new counter of batches cut off and run again (an Oban retry, a sweeper release, a dead script's takeover); the Mix task prints it.
  • The Jobs page's sweeper warning and Runs badge cover all runs, not the filtered page; the open run refreshes with the table; the filters use the core select.

Changed

  • Jobs is no longer a module you switch on: it is always on. It leaves the Modules page; jobs is a core section key and jobs.manage a sub-permission that is granted to Admin at boot and not backfilled to existing holders of jobs, who keep viewing. Jobs.enabled?/0, enable_system/0 and disable_system/0 remain, deprecated, as true and no-ops.
  • The capture-date backfill's worker starts a run instead of walking the library itself; its old job shape still arrives and does the same.

i18n

  • The Jobs page's new strings (the Runs tab, its controls, history and notices) in all seven locales.

2.47.0 - 2026-10-03

Changed

  • The bucket rows of a storage profile, and a library's storage, are saved with a Save button. Changing a row of the Storage profiles tab (role, stores, upload order, serve order, status) saved the instant a control changed or a number stopped moving, with no sign that anything had; the profile's own name and copy counts saved only on their button or on Enter. Libraries → a library's profile, variant set and annotated thumbnails saved on every dropdown change, and a different profile or variant set moves and resizes the library's files. Each of these is a form with one Save button now: disabled until something changes, then "Unsaved changes", then "Saved" (and the usual confirmation at the top). Core.SaveButton is the component, opt-in like date_nav.
  • Removing a bucket from a profile says what it does. The "x" is "Remove from profile", and the confirmation says the files are copied to the profile's other buckets and then deleted from this bucket, that the bucket itself stays, and that the profile stops sending it files.

Added

  • The Storage profiles tab says when a bucket is idle and offers the count that uses it. With one Primary and a Replica or Backup at one copy, the other bucket holds nothing, so a line under the copy counts names it ("Not used at this copy count: …") and a button — "Keep every original on 2 buckets" — sets the count after a confirmation that says existing files are copied in the background. The count is offered only where it is unambiguous (one primary, one copy, a bucket waiting); with several primaries a higher count would also put every file on every primary, so the line only names the idle buckets. The server applies only the count it would itself recommend. Profiles.copies_advice/1 is the one reading of what a count means for a profile's buckets.
  • Creating a bucket says when the Default now spreads files. A new bucket joins the Default as a primary; when that leaves it keeping fewer copies than it has primaries, the confirmation adds "Each original is stored on 1 of the 2 primary buckets, so files are spread across them, not mirrored", with where to change it.

Fixed

  • The line under a profile's copy counts treated every bucket as equal. With one Primary and one Replica at one copy it said files were spread at random across both; in fact the copy goes to the primary and the replica holds nothing unless a write to the primary fails. It now says so and, with several primaries, counts only them.

i18n

  • The strings the Save buttons, the idle-bucket line and the copy-count button added (9 in all) are extracted and translated in de, es, et, fr, it, pl and ru; no entry is fuzzy.

2.46.0 - 2026-10-02

Added

  • A library can choose annotated thumbnails for itself. Libraries → each library has an "Annotated thumbnails" select: site setting, on or off. Baking the annotation shapes into the grid thumbnail, regenerating it when annotations change, and showing it in the grid all follow the file's library, falling back to the site setting (Media Configuration) when the library has made no choice, so nothing changes until one does. Turning it on or off for a library does not bake or drop existing thumbnails: a file picks the change up the next time its annotations change, as with the site setting. It lives in the library's JSON settings, so there is no migration: Libraries.setting/2, setting_among/2 and put_setting/3 read and write a per-library setting (the keys are listed in Libraries and refused otherwise, a value of the wrong type is refused, and only the key changes), and AnnotationThumbnail.enabled_for?/1, enabled_for_file?/1 and enabled_among/1 apply the rule; AnnotationThumbnail.enabled?/0 is still the site default.

Changed

  • The Integrations lists say which service an Object Storage connection is. Every Object Storage connection read "Object Storage (S3-compatible)", so a Tigris connection could not be told from an Amazon or a Backblaze one. The table (Settings → Integrations and My Integrations) now shows the service — Tigris, Cloudflare R2, Backblaze B2, … — as a badge next to the provider, and as a "Service" row in the card view. A provider with a :setup_module can add its own with an optional label/1 (Providers.setup_label/2).
  • The Media Buckets list shows a Type and reads cleanly. The Provider column is a Type column: Local or Cloud, with the service a cloud bucket is on (the one its integration is for — Tigris, Cloudflare R2, …) as a badge beside it. Under the bucket's name, the location is in words — fotki-dev-test1 · t3.storage.dev for a cloud bucket, the path for a local one — instead of tigris:fotki-dev-test1https://t3.storage.dev.
  • The Storage profiles tab is clearer and lines up. The bucket rows sit on one grid, so each control is under its own heading and the dropdowns have fixed widths. "Pool" is "Upload order" (empty reads "Any"), with a tooltip on it and on Role and Serve order; the statuses read "Active", "Read-only (no new files)" and "Draining (moving files out)". The copy counts are "Copies of each original", "Copies of each size and tile" and "Copies needed to accept an upload", and a line under them says what they mean with the buckets the profile has now — two buckets at one copy spread files across them rather than mirror them, and a count above the number of buckets is flagged. The description no longer promises that every change moves files: renaming a profile or changing the copies an upload needs does not.
  • Media Configuration no longer has a second editor for redundancy, sizes and tiles. "Redundancy Copies", "Auto-Generate Variants" and "Deep Zoom Tile Generation" were leftovers from before profiles and variant sets: they edited the Default storage profile's copy count and the Default variant set's flags, which every library on another profile or set ignored, under names and with limits that disagreed with the real editors. Copies are set on the Storage profiles tab, and sizes and tiles on the variant set; the tab now says so, with links, and keeps what is genuinely site-wide: annotated thumbnails, image editing and the upload size limit. Nothing stored changes, and Storage.set_redundancy_copies/1, Storage.set_auto_generate_variants/1 and Storage.set_tile_generation/1 are as they were.

Fixed

  • A profile's "Used by" count left out the libraries that use it without naming it. The Default said "Used by 0 libraries" while the Media library, which names no profile and so uses the Default, said it was on it. The Default's count now includes the libraries with no profile of their own.

i18n

  • The strings the Media Buckets list, the Storage profiles tab, the Configuration tab and the per-library annotated thumbnails added (31 in all, one with plural forms) are extracted and translated in de, es, et, fr, it, pl and ru; no entry is fuzzy.

2.45.0 - 2026-10-02

Added

  • The Object Storage integration opens on a choice of service and asks only what that service needs. "S3-compatible" is a protocol, not a place: the form (site-wide and personal alike) now starts with a Service select — Amazon S3, Cloudflare R2, Backblaze B2, Tigris, Wasabi, DigitalOcean Spaces, or Other S3-compatible — and shows that service's own fields: a grouped region list for Amazon (from aws_regions), a region with suggestions for Backblaze, Wasabi and Spaces, the account id and optional jurisdiction for R2 (a pasted <id>.r2.cloudflarestorage.com is read for you), nothing but the keys for Tigris, and an endpoint for anything else. Field labels and help follow the service ("Application Key ID" for Backblaze, where in the console to find each key). The endpoint is built for you (s3.<region>.backblazeb2.com, <account>.r2.cloudflarestorage.com, t3.storage.dev, …); what is stored is still access_key, secret_key, region and endpoint, plus service, account_id and jurisdiction so an edit shows the choice again. A connection saved before this opens on the service its endpoint names, so nothing needs migrating. The logic is PhoenixKit.Integrations.ObjectStorageServices, reached through the new Providers.setup_fields/2, setup_attrs/2, setup_changed/3 and setup_saved/3 and a provider's optional :setup_module, so any provider can shape its form the same way. setup_field/1 gains :combo (free text with suggestions), grouped selects, a prompt and a field-level on_change.
  • The storage bucket form starts with a type, and cloud buckets start from an integration. Settings → Media → Add Storage Bucket asks for a Name (the PhoenixKit bucket's own label — it used to read "Bucket Name" and was mistaken for the bucket on the storage service) and a Type: Local Filesystem, which asks for the storage path, or Cloud storage, which lists the Object Storage integrations by name and service with an "Add a connection" link. Picking one sets the bucket's provider (R2, B2, Tigris, otherwise S3), fills the endpoint and region and then asks for the bucket's name on that service, labelled for it ("Bucket name on Backblaze B2"). Only Amazon has a region to choose; only "Other" has an endpoint to type; the rest come from the integration. Moving a bucket to another integration replaces what the first one filled in. The old Storage Provider select is gone from new buckets; existing buckets show their service and keep working.

Changed

  • A new Tigris bucket no longer asks for a region. The list of Tigris cities was only ever the request's signing region — Tigris is one global endpoint (t3.storage.dev) and places data by a setting of the bucket in its own console — so it read as a placement choice and was not one. New Tigris buckets sign with auto; existing ones keep the region they have.
  • Settings → Integrations is split into tabs. "Connections" and "Personal integrations" (which services users may connect on their own) are tabs of the page; the encryption key warning stays above them, so it is seen whichever one is open.
  • The tab of a settings page is in the URL. General (/admin/settings), Users, Authorization, Organization, Website access, Emails Transactional, Integrations, Crawlers, Media and Sitemap kept the active tab only in the page's state, so a refresh went back to the first tab and a tab could not be linked to or reached with the browser's Back button. Each tab now has its own URL (/admin/settings/users?tab=sessions); the first tab's URL carries no query and an unknown ?tab= opens it. The tabs patch, so the page is not reloaded and unsaved edits in a hidden tab are kept. PhoenixKitWeb.Live.Settings.UrlTabs (active/2, patch_links/2) is the shared helper. The switch_settings_tab event is gone from these pages; the Media page's "Details" link on the missing-tools warning is a link to ?tab=external_libraries now.

i18n

  • The strings the Object Storage service form and the storage bucket form added (37 in all) are extracted and translated in de, es, et, fr, it, pl and ru; no entry is fuzzy.

2.44.0 - 2026-10-01

Added

  • Email files can be written in Markdown, with a shared header, footer and layout groups (#887). A markdown.md part feeds both bodies: the HTML (links and a lone-link paragraph as accent-coloured buttons, tables, lists) and the text (label: url). {{variable}} / {{{variable}}} work as in an html part and a link target is checked after substitution — only http(s) and mailto: become links. The layout gains _header / _footer parts, and a group (layout: "billing" or the email's layout.txt) brings _layout-billing, _header-billing, _footer-billing, each falling back to the shared one. Content.resolve_with_sources/5 says where every part came from. Raises the phoenix_kit_templates floor to 0.2.2.
  • Email branding (#887, #890). Every part of a file email sees {{logo_url}} (the site logo as an absolute, permanently signed URL; empty for a private library's logo) and {{accent_color}}. Settings → Emails Transactional has a Branding tab with the accent colour (email_accent_color, #rrggbb, blank = neutral) and the logo emails carry; core's layout draws an accent bar only once a colour is set.
  • Admin email preview (#890). /admin/settings/email-sending/preview lists every email the site sends — core's eight and those enabled modules declare through the new PhoenixKit.Module.email_templates/0 callback (default []; collected by ModuleRegistry.all_email_templates/0, PhoenixKit.Email.Catalog) — renders the chosen one in a chosen language with sample values through the same resolution a send uses (HTML in a sandboxed iframe, text, subject), and says for each part whether it came from a database template, a host file or the built-in default, and which file overrides it. Gated by settings.

Changed

  • Core's default email copy lives in PhoenixKit.Email.CoreTemplates (one function per email), shared by the send and the preview; the text is unchanged.

Fixed

  • A dialect language preference rendered core's default copy in English (#889). A recipient or web visitor on es-ES matched no Gettext catalogue and read English; the locale is now chosen by RecipientLocale.gettext_locale/1 (the locale, its pt_BR spelling, then its base language), shared by the web and by emails and notifications rendered for a recipient.
  • A bare {{url}} on a line of a Markdown email is documented as text, not a link (write [label]({{url}})).

i18n

  • The strings the three PRs added (the Email preview page, the Branding tab, the core email labels — 69 per locale) were never extracted, so every locale showed them in English. They are extracted and translated in de, es, et, fr, it, pl and ru, and the fuzzy carry-overs the merge produced were rewritten by hand.

2.43.1 - 2026-10-01

Fixed

  • Uploaded and generated media no longer ship in the Hex package. priv/media is excluded even when its files are ignored by Git. The release gate inspects the built package and refuses media or generated sitemaps before publication.
  • Restricted Media viewers cannot change another person's visible folder. Folder colour, header and cover events use folder-uuid; the ownership guard now checks that parameter as well as the other folder identifiers.
  • Wall-clock cache deadlines survive slow loads and a busy cache process. Cache.remember/4 subtracts loading time from a boundary-based lifetime and skips caching a value loaded after its deadline. An explicit Cache.put/4 expiry is measured when called, so waiting in the cache's mailbox cannot extend it.
  • Admin-page generator tests no longer overwrite a concurrently running test's repo configuration. The generator tests run synchronously.

2.43.0 - 2026-10-01

Added

  • Media shows each viewer only what is theirs. A holder of media without the new media.view_all sees, in a site library, the files they uploaded, the folders they created or that hold their files, and those folders' ancestors: in the grid, folders, tree, search, counts, trash and orphans, and on the file's own page. An Owner/Admin or a holder of media.view_all sees everything, as before. Every event that names someone else's file or a folder the viewer cannot see is refused, so is changing a folder they did not create, and a hand-edited ?file= link opens only a file of the library shown that the viewer may see (it opened any file, of any library, before). A trashed file's URL answers its uploader, an Owner/Admin or a media.view_all holder; changing another person's file needs media.view_all. Emptying the trash and clearing orphans work for a restricted viewer on their own files only.

  • User libraries join Media's switcher, grouped "Site" and "Mine" (/admin/media/my/<id>). A holder of media who is not an Owner/Admin is sent there from /admin/libraries; that page remains for people who hold storage without media and for the admin's audited list of other users' libraries. The profile's Media tab has an "Open" link on every library.

  • Two sub-permissions of media: media.view_all (see everyone's files in the site's libraries) and media.manage (Settings → Media: buckets, sizes, health). They appear under Media in the permissions matrix.

  • Which services users may connect on their own is a site setting. Settings → Integrations has a "Personal integrations" card: a checkbox per provider that supports personal use, and the personal "add integration" page offers exactly the ticked ones. The list used to be fixed in code (Telegram and OpenRouter); until an admin saves a choice those two are still what users are offered, so nothing changes on upgrade. A provider opts in with personal_default: true (offered until the admin chooses), and Object Storage is also offered while users may keep a library on their own bucket (personal_also_while). The page refuses a provider it did not offer, even to a hand-made event.

  • A shared HTML layout for emails built from files and defaults (PhoenixKit.Email.Layout). Core's auth emails and the defaults a module passes to Mailer.send_from_template/4 ship no html part, so they went out as plain text; they now arrive as HTML — the site's name above the body, the name and a link to the site below it, inline styles, no brand colours, no external resources, and no words of its own to translate. A text-only message is escaped, split into paragraphs and its http(s):// addresses linked (a link ends at invisible and bidirectional format characters, so it cannot display one address and open another). A host overrides the layout with _layout/html.html under its phoenix_kit_templates root (needs phoenix_kit_templates 0.2.1; older releases skip the name and core's layout is used). A layout without {{{content}}} is refused with one warning. layout: false opts out; an html part that is already a whole document, and any database template, are never wrapped. A blank html or text part (an empty override file) is now treated as missing. See guides/email-templates.md.

  • Module UI standardisation, core side.

    • <.date_nav> — previous / today / next over a date held in the URL (stateless patch links, optional native picker, DateNav.parse_param/2 clamps to min/max). Not in the global import list; import it.
    • line_chart hover={:crosshair} — a snapping crosshair with a server-computed readout (x, value, optional point_note, the rows active at that x), keyboard (arrows, Home/End, Escape), touch (tap to pin) and a polite live region; the native tooltips stay as the no-JS readout.
    • SearchPicker draws its list in the top layer (a modal body or overflow ancestor no longer cuts it off), closes on blur, picks on touch-up, and takes close_on_pick, row_layout="stacked" and the input's form/maxlength/required/inputmode.
    • table_default gets a toolbar_primary slot, bulk_actions_toolbar a primary slot, form_actions submit_disabled, form_section an :actions slot, an optional title, and page_action show_label. Utils.Reorder and sortable_row take non-UUID keys; modals take heights in dvh as well as vh.
    • Utils.Date.short/1 and short_with_year/1 — localized day-month dates.
  • Settings → Users → Profile Page: an admin chooses which sections the user settings page shows (custom fields, email, start page, annotation reset, password, connected accounts, sessions, notifications, and the Google address inside the identity form). What is stored is the list of hidden sections, so a section added later shows up; a tab whose sections are all hidden is not offered; name and avatar cannot be hidden. The toggle is attributed to the admin. It hides the section on the page — it is not an access control.

  • Storage.store_from_url/2 — a fetch that cannot reach the server's own network: public addresses only (Utils.PublicAddress, IPv4 and IPv6, with config :phoenix_kit, :blocked_ip_ranges for ranges only your network knows), every redirect re-checked, https never downgraded to http, ports 80/443, size and time capped, bytes sniffed for the type. URLSigner.verify_url/2 is the file route's own check for a file URL.

  • Cache.remember/4 — get-or-compute with a wall-clock expiry (until: {:end_of_day, "Europe/Tallinn"}, :end_of_hour, :end_of_minute); errors are returned, never stored. Cache.put/4 takes expires_in:.

  • PhoenixKitWeb.Plugs.ProbeBlock — answers scanner probes (/.env, wp-login.php, *.php) with a bare 404; /.well-known/ always passes; extra: / except: adjust it.

  • Sitemap: config :phoenix_kit, sitemap: [extra_sources: [...]] adds sources without restating the defaults; a route with metadata: %{sitemap: false} stays out of the sitemap. The doctor warns when robots.txt (a static file or a routed one) has no Sitemap: line, and counts image sizes made by an older pipeline.

  • Host hooks: config :phoenix_kit, host_live_view_locale: :leave keeps a host LiveView's own locale; host_anonymous_scope: {Mod, :fun, args} gives the host layout its own scope when nobody is signed in (display only). hidden_admin_tabs now also covers host, legacy and runtime-registered admin tabs.

Fixed

  • PhoenixKit.System.Dependencies compiles on OTP 28. The external-tools list held compiled ~r sigils in a module attribute, which OTP 28 cannot escape into a function body; it is built in a function now.

  • A file that merely starts with the letters BM is no longer an image. A CSV or note beginning "BMI,weight…" was sniffed as a BMP and stored as image/bmp; a BMP must also carry a valid file header.

  • A grayscale image is no longer treated as see-through. gray contains the letter the alpha check looked for, so black-and-white photos were written as PNG instead of JPEG.

  • An animated GIF or WebP resized to a still format makes its file. ImageMagick wrote one file per frame and never the one asked for, so the size failed on every retry; frame 0 is used unless the target is itself a GIF or WebP.

  • A download whose response ends with trailers is kept. The trailers re-opened the temporary file and orphaned the downloaded one (Storage.store_from_url/2).

  • Cache.remember/4 measures its boundary from before the load. A load straddling midnight kept yesterday's value for a day more; end_of_minute/end_of_hour are also right across the hour the clocks go back.

  • host_anonymous_scope no longer takes the login pages down. The host's function runs only when the layout has no scope yet, and one that raises leaves the scope absent and logs.

  • The hidden-profile-sections read fails open on a dead pool too (it caught raises, not exits).

  • The profile's tabs stay on the personal integration "add" and edit pages. They disappeared after "Add integration", so the page no longer said where you were.

  • The Multiple Sessions setting reads "Enable multiple sessions" (it said "multi-account switcher in the header"; the switcher is in the user menu).

  • Image sizes keep transparency and are never enlarged. A see-through image gets a PNG (or variant_alpha_format) size instead of a black-backed JPEG, and a size larger than its original is the original's size. Sizes made earlier keep working; VariantSets.remake_all/0 remakes them (manual — it queues every size of every file — and every spec hash changed, so an ordinary revision bump of a size set also makes that set's files stale).

  • ImageMagick is pinned to the sniffed format and limited on every call. A file is decoded only by the coder its bytes name, never one chosen from its file name; bytes nothing recognises under an image/* claim are stored as application/octet-stream.

  • The doctor no longer leaves a pooled connection with an empty search_path. Behind PgBouncer in transaction mode its session-level SET/RESET landed on different backends, and a host's pages then failed with "relation does not exist" until the pool was reset; the setting is now transaction-local. PostgresPreflight no longer calls a PgBouncer login rejection "unreachable".

  • table_default keeps its toolbar on an empty plain table, gen.admin.page stops rewriting the host's tab list and pointing gettext at the kit, PublicAddress no longer treats reserved IPv6 space as public, the SearchPicker list no longer sticks on "Searching…" when focus is elsewhere, and phone form fields no longer zoom iOS (including kit pages inside a host layout).

  • Core compiles on Elixir 1.18 with OTP 28.

Changed

  • phoenix_kit_templates floor is ~> 0.2.1. Core's email layout looks up the host's _layout override, which 0.2.0 skips.
  • The storage administration screens (Settings → Media: buckets, sizes, profiles, health) need media.manage, no longer just media. So giving an end user Media does not hand them the bucket form.

Upgrading

  • Every role that holds media when this boots is granted media.view_all and media.manage once (and Admin gets them with the other new keys), so nothing changes for existing roles. A role created afterwards gets neither by default: a holder of media alone sees only their own files. Revoke media.view_all from a role to make it so; it is not given back.
  • The media pickers (MediaSelectorModal and the featured-image picker) are not filtered by viewer.

Older releases are archived by quarter in dev_docs/changelogs/: 2026 Q3 · 2026 Q2 · 2026 Q1 · 2025