The installed app's icons follow the chosen logo, drawn by the browser (#229)

An app installed from the browser or added to an iPhone home screen kept the wax seal. The manifest and apple-touch-icon now name the chosen logo; the browser draws the PNGs on a canvas and uploads them per user, so the image needs no librsvg. Builds on #92. Closes #141.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 18:16:55 +02:00
1 parent fbf7c0ea6f
commit cdd0cd4fd8
20 files changed
+1598 -26

No files matched your search

+9
View File
@@ -8,6 +8,15 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**The installed app has the icon you picked, on iPhone and Android too.** The tab icon followed
Settings → Appearance → Logo, but an app installed from the browser or added to a home screen kept
the wax seal, because its icons came from fixed files. The install manifest and the iOS home-screen
icon now name your choice. Your browser draws them as PNGs when you pick a logo and uploads them for
your account, so the image needs nothing new to draw them; a logo picked before this is drawn by the
next page you open. An app that is already installed keeps its old icon until the browser refreshes
it or you reinstall. No migration. [#141](https://github.com/karatektus/pl_mail/issues/141), building
on #92 by @Flouss.
**The weekly cleanup no longer deletes profile pictures, signature images and backgrounds.** Every
Sunday at 05:00 `app:prune:blobs` removed week-old files it found no record for anywhere under
`var/uploads`, `var/attachments` and `var/raw`. Profile pictures, signature images and appearance
+104
View File
@@ -0,0 +1,104 @@
import { csrfToken } from './csrf.js';
/**
* The installed app's icons, drawn by this browser and handed to the server
* (#141).
*
* An app installed from the browser, or put on an iPhone's home screen, takes
* its icon from PNGs: the manifest's, and iOS's apple-touch-icon. The logo is
* an SVG with 330 faces. Drawing those PNGs on the server took librsvg in the
* image, about 60 MB for everybody, and the Imagick already there paints a
* gradient stroke black. A browser draws the SVG correctly by definition, so
* the page draws it on a canvas, at the four sizes, and uploads them. The
* server checks each one is a PNG of exactly its size and keeps it for this
* user only (AppIconStorage).
*
* Two callers: the appearance pane when a logo is picked, and any page whose
* user has no PNGs for their current choice yet (core--app-icons).
*/
/** name => [which SVG, pixels square]; the names AppIconStorage::ICONS uses. */
const ICONS = {
'tile-192': ['tile', 192],
'tile-512': ['tile', 512],
'bleed-512': ['bleed', 512],
'bleed-180': ['bleed', 180],
};
/**
* One SVG drawn as a PNG of $size by $size.
*
* The SVG's own width and height are set to the target first, so the browser
* rasterises the vector at that size rather than scaling up a 72-pixel
* drawing. It is loaded from a blob URL: same origin, so the canvas is not
* tainted and toBlob() is allowed (the layout's img-src allows blob:).
*/
async function draw(svgUrl, size) {
const response = await fetch(svgUrl, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`${svgUrl} answered ${response.status}`);
}
const svg = new DOMParser().parseFromString(await response.text(), 'image/svg+xml').documentElement;
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }));
try {
const image = new Image();
image.src = url;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
canvas.getContext('2d').drawImage(image, 0, 0, size, size);
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob gave nothing'))), 'image/png');
});
} finally {
URL.revokeObjectURL(url);
}
}
/**
* Draw the four PNGs of one choice and upload them.
*
* $tileUrl and $bleedUrl are the choice's tile and bleed SVGs as the server
* generated them: `/branding/{icon|bleed}/{motif}/{paint}.svg?v={version}`,
* so the choice and the drawing's version are read off the tile's URL rather
* than passed alongside it, and cannot disagree with what was drawn.
*
* Resolves to the upload's status. 409 is not an error: the page is older
* than the drawing (a deploy happened), and the next page load draws the
* current one.
*/
export async function uploadAppIcons({ uploadUrl, tileUrl, bleedUrl }) {
const tile = new URL(tileUrl, window.location.origin);
const [, motif, paint] = tile.pathname.match(/\/([^/]+)\/([^/]+)\.svg$/) ?? [];
if (!motif || !paint) {
throw new Error(`not a logo URL: ${tileUrl}`);
}
const body = new FormData();
body.append('motif', motif);
body.append('paint', paint);
body.append('version', tile.searchParams.get('v') ?? '');
for (const [name, [form, size]] of Object.entries(ICONS)) {
body.append(name, await draw(form === 'tile' ? tileUrl : bleedUrl, size), `${name}.png`);
}
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken() },
body,
});
return response.status;
}
@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
import { uploadAppIcons } from "../../app_icons.js"
/**
* Draws the installed app's icons once, when the server says the user's PNGs
* for their current logo are missing (#141).
*
* That happens for a logo picked before the PNGs existed, and after a deploy
* that changed the drawing (its version is part of where they are kept). The
* layout renders this element only then, so most pages never load it. Until
* the upload lands, the icon route answers with the wax seal.
*
* Once per choice per document: Turbo renders the layout again on every
* visit, and the element keeps coming back until a page is rendered after the
* upload. The set below stops those visits from drawing it again.
*/
const done = new Set();
export default class extends Controller {
static values = {
tileUrl: String,
bleedUrl: String,
uploadUrl: String,
};
async connect() {
if (done.has(this.tileUrlValue)) {
return;
}
done.add(this.tileUrlValue);
try {
await uploadAppIcons({
uploadUrl: this.uploadUrlValue,
tileUrl: this.tileUrlValue,
bleedUrl: this.bleedUrlValue,
});
} catch (error) {
// Nothing the user can act on, and the wax seal stands in: let a
// later page load try again rather than shouting about it.
done.delete(this.tileUrlValue);
console.warn("app icons: could not draw or upload", error);
}
}
}
@@ -1,6 +1,7 @@
import { Controller } from '@hotwired/stimulus';
import { csrfToken, jsonCsrfHeaders } from '../../csrf.js';
import { requestFailed } from '../../request_errors.js';
import { uploadAppIcons } from '../../app_icons.js';
export default class extends Controller {
static values = {
@@ -8,6 +9,7 @@ export default class extends Controller {
uploadUrl: String,
importUrl: String,
resetUrl: String,
appIconsUrl: String,
logoDefault: String,
};
@@ -23,6 +25,11 @@ export default class extends Controller {
this.root = document.documentElement;
this.pending = null;
// The choice the installed app's PNGs were last drawn for. The page's
// own choice is the layout's business (core--app-icons draws it when it
// is missing), so only a change from here on is drawn by this pane.
this.appIconsChoice = this.hasLogoMotifTarget ? `${this.logoMotifTarget.value}/${this.logoPaint()}` : null;
if (this.hasInkColorFieldTarget && this.inkColorFieldTarget.value !== '') {
this.applyInk();
}
@@ -574,26 +581,69 @@ export default class extends Controller {
document.querySelectorAll('link[rel="icon"][type="image/svg+xml"]').forEach((link) => {
this.setIfChanged(link, 'href', this.iconUrl(motif, paint, 'logoFavicon'));
});
// The install manifest and iOS's home-screen icon name their pictures
// by the same choice, so an app installed from this page, before a
// reload, gets the pick (#141).
document.querySelectorAll('link[rel="manifest"]').forEach((link) => {
this.setIfChanged(link, 'href', this.iconUrl(motif, paint, 'logoManifest'));
});
document.querySelectorAll('link[rel="apple-touch-icon"]').forEach((link) => {
this.setIfChanged(link, 'href', this.iconUrl(motif, paint, 'logoAppleTouch'));
});
this.drawAppIcons(motif, paint);
}
/**
* Draw the installed app's PNGs for a new choice and upload them (see
* app_icons.js for why the browser draws them).
*
* Only for a choice that changed: repaintLogo() also runs for a theme
* switch that leaves the icon as it was. And not on every click: somebody
* walking through the colourways would upload four PNGs per tile, so it
* waits until the clicking stops. A page left before that finds the PNGs
* missing on the next load, and draws them there.
*/
drawAppIcons(motif, paint) {
const choice = `${motif}/${paint}`;
if (!this.hasAppIconsUrlValue || choice === this.appIconsChoice) {
return;
}
this.appIconsChoice = choice;
clearTimeout(this.appIconsTimer);
this.appIconsTimer = setTimeout(() => {
uploadAppIcons({
uploadUrl: this.appIconsUrlValue,
tileUrl: this.iconUrl(motif, paint),
bleedUrl: this.iconUrl(motif, paint, 'logoBleed'),
}).catch((error) => console.warn('app icons: could not draw or upload', error));
}, 600);
}
/**
* One motif in one paint, as a tile (the default) or in another of the
* forms its button carries a URL for: `logoFavicon`, `logoBar`,
* `logoBarDark`.
* forms its button carries a URL for: `logoFavicon`, `logoBleed`,
* `logoManifest`, `logoAppleTouch`, `logoBar`, `logoBarDark`.
*
* Built from the motif button's own URL rather than from a pattern: that
* URL IS the route for the motif in its original design, generated by the
* server with the drawing's version in its query, and a paint is the last
* path segment — so any other paint is the same URL with `original.svg`
* swapped for `<paint>.svg`, and the query survives untouched. A pattern
* rendered for the script to fill could not be generated at all: the
* route's requirements refuse a placeholder.
* server with the drawing's version (and for the installed app's, the
* user's key) in it, and a paint is the last path segment — so any other
* paint is the same URL with `original.svg` (or `.webmanifest`, `.png`)
* swapped for `<paint>` and the same extension, and the query survives
* untouched. A pattern rendered for the script to fill could not be
* generated at all: the route's requirements refuse a placeholder.
*/
iconUrl(motif, paint, form = 'logoIcon') {
const tile = this.element.querySelector(`[data-logo-motif="${motif}"]`);
const url = new URL(tile.dataset[form], window.location.origin);
url.pathname = url.pathname.replace(/[^/]+\.svg$/, `${paint}.svg`);
url.pathname = url.pathname.replace(/[^/]+(\.(?:svg|webmanifest|png))$/, `${paint}$1`);
return url.toString();
}
+5
View File
@@ -160,6 +160,11 @@ services:
arguments:
$projectDir: '%kernel.project_dir%'
$storageDir: '%env(APP_STORAGE_DIR)%'
App\Domain\Helper\AppIconStorage:
arguments:
$projectDir: '%kernel.project_dir%'
$storageDir: '%env(APP_STORAGE_DIR)%'
$secret: '%kernel.secret%'
App\Command\Maintenance\PruneBlobsCommand:
arguments:
$projectDir: '%kernel.project_dir%'
+7 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/appearance.md sha1:963b5496c55b39c78ef14815a4c3c0e5b2ae8f78 -->
<!-- translated-from: features/appearance.md sha1:07ca79710a14017f094316936796ec53ba56ebb6 -->
# Darstellung
@@ -64,6 +64,12 @@ Oberflächen hat — ein tintendunkles Symbol verschwindet so nie in einer dunkl
Tab-Symbol behält die Kachel bei jedem Symbol außer dem pl-Zeichen, weil eine Zeichnung ohne
Hintergrund in Tab-Größe kaum zu erkennen ist.
Eine aus dem Browser installierte App, oder plMail auf dem Home-Bildschirm eines iPhones, trägt es
ebenfalls. Dieses Symbol zeichnet dein Browser, wenn du ein Logo wählst, und es wird für dein Konto
aufbewahrt. Nach einem Update, das die Zeichnung ändert, zeichnet die erste Seite, die du öffnest,
es im Hintergrund neu. Eine schon installierte App behält ihr altes Symbol, bis der Browser es
auffrischt oder du sie neu installierst.
## Die Live-Vorschau
Neben den Bedienelementen steht eine zweite Karte, die eine Beispiel-Seitenleiste, eine
+5
View File
@@ -56,6 +56,11 @@ dark-chrome colours, so an ink-dark icon never disappears into a dark top bar. T
the tile for every icon but the pl mark, because a drawing with nothing behind it is hard to make
out at the size of a tab.
An app installed from the browser, or plMail added to an iPhone's Home Screen, wears it too. Your
browser draws that icon when you pick a logo, and it is kept for your account. After an update that
changes the drawing, the first page you open draws it again in the background. An app that is
already installed keeps its old icon until the browser refreshes it or you install it again.
## The live preview
Beside the controls sits a second card showing a sample sidebar, message list and reading pane in
+155 -8
View File
@@ -6,9 +6,11 @@ namespace App\Controller;
use App\Domain\Enum\Theme\LogoMotif;
use App\Domain\Enum\Theme\LogoStyle;
use App\Domain\Helper\AppIconStorage;
use App\Infrastructure\Routing\LogoPaintRequirement;
use App\Service\Appearance\LogoIcons;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -16,7 +18,8 @@ use Symfony\Component\Routing\Requirement\EnumRequirement;
/**
* The logo, drawn three ways: as the icon tile, as the tab icon, and as the
* top bar wears it.
* top bar wears it. Beside them, the installed app's: its manifest, and the
* PNGs the user's browser drew for it (#141).
*
* plMail's logo has three hundred and thirty faces (Settings → Appearance →
* Logo: ten icons, each in its own design or thirty-two colourways), and every
@@ -33,8 +36,8 @@ use Symfony\Component\Routing\Requirement\EnumRequirement;
* already fetched correctly (the one the page loaded with). Drawn from the URL,
* there is nothing for the save to race.
*
* All three are public and, at the current drawing version, immutable. See
* serve() for why that is safe.
* All of them are public and, at the current drawing version, immutable. See
* serve() for why that is safe, and appIcon() for the one exception.
*/
final class BrandingController extends AbstractController
{
@@ -54,7 +57,10 @@ final class BrandingController extends AbstractController
private const int UNVERSIONED = 3_600;
public function __construct(
private readonly LogoIcons $icons,
private readonly LogoIcons $icons,
private readonly AppIconStorage $appIcons,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {}
/**
@@ -103,6 +109,112 @@ final class BrandingController extends AbstractController
);
}
/**
* The tile with its ground out to every edge, for what cuts its own
* corners: iOS's home-screen icon and a manifest's maskable icon. The
* browser draws this into the PNGs of appIcon(); see icon.svg.twig.
*/
#[Route(
'/branding/bleed/{motif}/{paint}.svg',
name: 'app_branding_bleed',
requirements: ['motif' => new EnumRequirement(LogoMotif::class), 'paint' => new LogoPaintRequirement()],
methods: ['GET'],
)]
public function bleed(Request $request, LogoMotif $motif, string $paint): Response
{
$style = LogoStyle::tryFrom($paint);
return $this->serve($request, 'bleed.' . LogoIcons::choice($motif, $style), fn (): string => $this->tile($motif, $style, true));
}
/**
* The install manifest: public/manifest.webmanifest with the choice's icons.
*
* Everything but the icons is read from that file, so the app's name, start
* page and colours have one home, and the file stays what an installer that
* never loaded a page would find.
*
* `?u=` is the user's key (AppIconStorage): with it the icons are that
* user's PNGs of this choice, without it (a page nobody is signed in to)
* the file's own wax seal. Only the choice's icons and nothing else: a
* launcher chooses among a manifest's icons the way Firefox chooses a tab
* icon (see _favicon.html.twig), and a second candidate that is the same
* for everyone wins for someone.
*/
#[Route(
'/branding/manifest/{motif}/{paint}.webmanifest',
name: 'app_branding_manifest',
requirements: ['motif' => new EnumRequirement(LogoMotif::class), 'paint' => new LogoPaintRequirement()],
methods: ['GET'],
)]
public function manifest(Request $request, LogoMotif $motif, string $paint): Response
{
$style = LogoStyle::tryFrom($paint);
$key = (string) $request->query->get('u', '');
if ('' !== $key && 1 !== preg_match('/^' . AppIconStorage::KEY_PATTERN . '$/', $key)) {
throw $this->createNotFoundException();
}
return $this->serve(
$request,
'manifest.' . LogoIcons::choice($motif, $style) . ('' === $key ? '' : '.' . $key),
fn (): string => $this->manifestJson($motif, $style, '' === $key ? null : $key),
'application/manifest+json',
);
}
/**
* One of a user's installed-app PNGs, as their browser drew it.
*
* The one picture here that is not a drawing of its URL: it is a file the
* user uploaded (AppIconStorage), and it may not be there yet, for a choice
* picked before this existed or after a deploy that changed the drawing.
* Then the wax seal's static PNG of the same shape stands in, so a manifest
* never names an icon that 404s, and it is served `no-cache`: the user's own
* arrives with the next page load, and must not be shadowed by the stand-in
* for a year.
*/
#[Route(
'/branding/app-icon/{key}/{name}/{motif}/{paint}.png',
name: 'app_branding_app_icon',
requirements: [
'key' => AppIconStorage::KEY_PATTERN,
'name' => 'tile-192|tile-512|bleed-512|bleed-180',
'motif' => new EnumRequirement(LogoMotif::class),
'paint' => new LogoPaintRequirement(),
],
methods: ['GET'],
)]
public function appIcon(Request $request, string $key, string $name, LogoMotif $motif, string $paint): Response
{
$choice = LogoIcons::choice($motif, LogoStyle::tryFrom($paint));
$version = $this->icons->version();
$stored = $this->appIcons->pathFor($key, $choice, $version, $name);
if (null === $stored) {
$response = new Response(headers: ['Content-Type' => 'image/png']);
$response->setPublic();
$response->headers->addCacheControlDirective('no-cache');
$response->setEtag('app-icon.fallback.' . $name . '.' . $version);
if ($response->isNotModified($request)) {
return $response;
}
return $response->setContent((string) file_get_contents($this->projectDir . '/public/' . AppIconStorage::FALLBACK[$name]));
}
// The upload can be replaced under the same URL (the same choice drawn
// again), so the ETag is the file's, not only the URL's.
return $this->serve(
$request,
sprintf('app-icon.%s.%s.%s.%s', $key, $choice, $name, (string) filemtime($stored)),
static fn (): string => (string) file_get_contents($stored),
'image/png',
);
}
/**
* The logo as the app's own chrome wears it: the top bar's.
*
@@ -147,11 +259,11 @@ final class BrandingController extends AbstractController
*
* @param \Closure(): string $draw
*/
private function serve(Request $request, string $choice, \Closure $draw): Response
private function serve(Request $request, string $choice, \Closure $draw, string $type = 'image/svg+xml'): Response
{
$version = $this->icons->version();
$response = new Response(headers: ['Content-Type' => 'image/svg+xml']);
$response = new Response(headers: ['Content-Type' => $type]);
$response->setPublic();
$response->setEtag($choice . '.' . $version);
@@ -171,8 +283,42 @@ final class BrandingController extends AbstractController
return $response;
}
/** One tile: the glyph on its ground, clipped to the rounded square. */
private function tile(LogoMotif $motif, ?LogoStyle $paint): string
/**
* The static manifest with the icons replaced: the user's PNGs of this
* choice when there is a key, the file's own wax seal otherwise.
*/
private function manifestJson(LogoMotif $motif, ?LogoStyle $paint, ?string $key): string
{
/** @var array<string, mixed> $manifest */
$manifest = json_decode(
(string) file_get_contents($this->projectDir . '/public/manifest.webmanifest'),
true,
flags: JSON_THROW_ON_ERROR,
);
if (null !== $key) {
$icon = fn (string $name, string $purpose): array => [
'src' => $this->icons->appIconUrl($name, $key, $motif, $paint),
'sizes' => sprintf('%1$dx%1$d', AppIconStorage::ICONS[$name]),
'type' => 'image/png',
'purpose' => $purpose,
];
$manifest['icons'] = [
$icon('tile-192', 'any'),
$icon('tile-512', 'any'),
$icon('bleed-512', 'maskable'),
];
}
return json_encode($manifest, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}
/**
* One tile: the glyph on its ground, clipped to the rounded square, or
* with the ground out to every edge when $bleed (see icon.svg.twig).
*/
private function tile(LogoMotif $motif, ?LogoStyle $paint, bool $bleed = false): string
{
[$parts, $glyphRamp, $tileRamp] = self::fill($motif->paints($paint));
@@ -181,6 +327,7 @@ final class BrandingController extends AbstractController
'p' => $parts,
'glyph_ramp' => $glyphRamp,
'tile_ramp' => $tileRamp,
'bleed' => $bleed,
]);
}
@@ -7,8 +7,12 @@ namespace App\Controller\Settings;
use App\Controller\ChecksCsrf;
use App\Domain\Enum\Mail\ReadingPaneMode;
use App\Domain\Enum\Theme\BackgroundKind;
use App\Domain\Enum\Theme\LogoMotif;
use App\Domain\Enum\Theme\LogoStyle;
use App\Domain\Helper\AppIconStorage;
use App\Entity\Embeddable\Appearance;
use App\Entity\User\User;
use App\Service\Appearance\LogoIcons;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -144,6 +148,61 @@ final class AppearanceController extends AbstractController
]);
}
/**
* Keeps the installed app's PNGs, as this user's browser drew them (#141).
*
* The appearance pane draws the chosen icon into four PNGs on a canvas
* when a logo is picked, and any page does when the current choice's are
* missing (see app_icons.js); this stores them, for this user only. Why
* the browser draws them rather than the server is in AppIconStorage.
*
* Nothing the browser says is taken on trust: the choice has to be one
* the routes can name, and each file a PNG of exactly its size and small
* (AppIconStorage::isAcceptable()). A drawing version that is not this
* server's means the page was loaded before a deploy changed the drawing:
* 409, and the page's next load draws the current one.
*/
#[Route('/app-icons', name: 'app_icons', methods: ['POST'])]
public function appIcons(Request $request, AppIconStorage $storage, LogoIcons $icons): Response
{
$this->assertCsrf($request, 'ajax');
/** @var User $user */
$user = $this->getUser();
$motif = LogoMotif::tryFrom($request->request->getString('motif'));
$paint = $request->request->getString('paint');
if (null === $motif || false === in_array($paint, LogoMotif::paintWires(), true)) {
return new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($icons->version() !== $request->request->getString('version')) {
return new Response(null, Response::HTTP_CONFLICT);
}
$pngs = [];
foreach (array_keys(AppIconStorage::ICONS) as $name) {
$file = $request->files->get($name);
$bytes = $file instanceof UploadedFile && true === $file->isValid() ? (string) file_get_contents($file->getPathname()) : '';
if (false === $storage->isAcceptable($name, $bytes)) {
return new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY);
}
$pngs[$name] = $bytes;
}
$choice = LogoIcons::choice($motif, LogoStyle::tryFrom($paint));
$storage->store((string) $user->id, $choice, $icons->version(), $pngs);
// Which choice was kept: several uploads can be in flight (a pick, and
// the layout drawing a missing one), and the answer says which is which.
return $this->json(['choice' => $choice]);
}
#[Route('/background', name: 'background_upload', methods: ['POST'])]
public function uploadBackground(Request $request): JsonResponse
{
+205
View File
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Domain\Helper;
/**
* The installed app's icons, as PNGs the user's own browser drew (#141).
*
* iOS takes nothing but a PNG for its home-screen icon and a launcher wants
* raster icons from the manifest, while the logo is an SVG with 330 faces.
* Drawing those on the server needed librsvg in the image (about 60 MB for
* everybody, for a feature only people with a home-screen icon and a
* non-default logo notice), and the Imagick already in the image paints a
* gradient stroke black. A browser draws the SVG correctly by definition, so
* the appearance pane draws the four PNGs on a canvas and uploads them, and
* this keeps them.
*
* WHY PER USER, AND WHY A KEY INSTEAD OF THE USER ID
* ──────────────────────────────────────────────────
* Per user, not shared per choice: an upload only ever changes its uploader's
* own icons, so a page that uploads something odd draws it for nobody else.
* A browser fetches a manifest and its icons without credentials, so the URL
* has to say whose icons they are. It says so with an HMAC of the user id
* rather than the id: nobody can walk the ids and collect everybody's logo.
*
* WHAT IS KEPT
* ────────────
* One choice per user: `<key>/<motif>.<paint>.<version>/<form>-<size>.png`. A
* new upload replaces whatever was there, because only the current choice is
* ever linked (an older one could only be named by a page loaded before the
* pick, and that page's icon route falls back to the wax seal). The drawing's
* version is in the directory, so a deploy that changes the drawing makes the
* old PNGs missing, and the next page load draws them again.
*
* Not under `uploads/`: the weekly blob prune sweeps that tree for files no
* row names (#214), and nothing here has a row. Losing these would cost
* nothing worse than a redraw, but they have no business being swept.
*/
readonly class AppIconStorage
{
/**
* The four PNGs, by the name the upload and the URL use: the rounded tile
* at a manifest's two sizes, the ground-to-the-edge form as its maskable
* icon, and the same at 180 for iOS's apple-touch-icon.
*
* @var array<string, int> name => pixels square
*/
public const array ICONS = [
'tile-192' => 192,
'tile-512' => 512,
'bleed-512' => 512,
'bleed-180' => 180,
];
/**
* The wax seal's static PNG for each, served when a user's own is missing,
* so a manifest never names an icon that 404s.
*
* @var array<string, string> name => path under public/
*/
public const array FALLBACK = [
'tile-192' => 'icons/icon-192.png',
'tile-512' => 'icons/icon-512.png',
'bleed-512' => 'icons/icon-maskable-512.png',
'bleed-180' => 'icons/apple-touch-icon.png',
];
/**
* Far above what a flat logo needs at 512 (a browser's PNG of one is tens
* of KB), far below anything worth abusing.
*/
public const int MAX_BYTES = 512 * 1024;
/** A user's key as the URL spells it; the routes' requirement. */
public const string KEY_PATTERN = '[0-9a-f]{20}';
private const string PNG_SIGNATURE = "\x89PNG\r\n\x1a\n";
public function __construct(
private string $projectDir,
private string $storageDir,
private string $secret,
) {
}
/** The URL's stand-in for a user id: stable, and not reversible to it. */
public function keyFor(string $userId): string
{
return substr(hash_hmac('sha256', 'app-icons:' . $userId, $this->secret), 0, 20);
}
/**
* Whether $bytes may be stored as $name: a real PNG, exactly the size the
* name promises, and small. The browser's word is not taken for any of it.
*/
public function isAcceptable(string $name, string $bytes): bool
{
$size = self::ICONS[$name] ?? null;
if (null === $size || strlen($bytes) > self::MAX_BYTES || false === str_starts_with($bytes, self::PNG_SIGNATURE)) {
return false;
}
$info = @getimagesizefromstring($bytes);
return false !== $info && IMAGETYPE_PNG === $info[2] && $size === $info[0] && $size === $info[1];
}
/**
* Keep a user's four PNGs for one choice, replacing whatever they had.
*
* Written into a fresh directory and renamed into place, so a request
* reading them never sees half a set.
*
* @param array<string, string> $pngs name => bytes, every name in ICONS, each isAcceptable()
*/
public function store(string $userId, string $choice, string $version, array $pngs): void
{
$root = $this->root($this->keyFor($userId));
$staging = $root . '/.incoming-' . bin2hex(random_bytes(6));
if (false === is_dir($staging) && false === mkdir($staging, 0755, true) && false === is_dir($staging)) {
throw new \RuntimeException(sprintf('Could not create %s.', $staging));
}
foreach (self::ICONS as $name => $size) {
file_put_contents($staging . '/' . $name . '.png', $pngs[$name]);
}
$final = $root . '/' . $this->set($choice, $version);
$this->deleteTree($final);
rename($staging, $final);
// Only the choice just stored is ever linked; see the class comment.
foreach (glob($root . '/*', GLOB_ONLYDIR) ?: [] as $other) {
if ($other !== $final) {
$this->deleteTree($other);
}
}
}
/** Whether the user's four PNGs for this choice and drawing are all there. */
public function has(string $userId, string $choice, string $version): bool
{
$directory = $this->root($this->keyFor($userId)) . '/' . $this->set($choice, $version);
foreach (array_keys(self::ICONS) as $name) {
if (false === is_file($directory . '/' . $name . '.png')) {
return false;
}
}
return true;
}
/**
* The stored PNG for a key, choice, drawing and name, or null when there is
* none. $key is the URL's, so it is checked here too, not only by the route.
*/
public function pathFor(string $key, string $choice, string $version, string $name): ?string
{
if (1 !== preg_match('/^' . self::KEY_PATTERN . '$/', $key) || false === isset(self::ICONS[$name])) {
return null;
}
$path = $this->root($key) . '/' . $this->set($choice, $version) . '/' . $name . '.png';
return true === is_file($path) ? $path : null;
}
public function deleteAllFor(string $userId): void
{
$this->deleteTree($this->root($this->keyFor($userId)));
}
private function root(string $key): string
{
return $this->projectDir . '/' . $this->storageDir . '/app-icons/' . $key;
}
/** `<motif>.<paint>.<version>`: the choice as LogoIcons::choice() spells it, and the drawing. */
private function set(string $choice, string $version): string
{
return $choice . '.' . $version;
}
private function deleteTree(string $directory): void
{
if (false === is_dir($directory)) {
return;
}
// scandir, not glob: the staging directories start with a dot, and
// glob's way of matching those (GLOB_BRACE) is missing on some libcs.
foreach (array_diff(scandir($directory) ?: [], ['.', '..']) as $entry) {
$path = $directory . '/' . $entry;
true === is_dir($path) ? $this->deleteTree($path) : @unlink($path);
}
@rmdir($directory);
}
}
+37 -2
View File
@@ -62,6 +62,9 @@ final class LogoIcons
'templates/branding/*.svg.twig',
'templates/branding/motif/*.svg.twig',
'templates/_partials/_logo_mark.html.twig',
// Not a drawing, but the manifest route serves it and promises its URL
// is immutable, so an edit to it must move the URL like one.
'public/manifest.webmanifest',
];
/** @var list<class-string> */
@@ -94,6 +97,38 @@ final class LogoIcons
return $this->generate('app_branding_favicon', $motif, $paint);
}
/**
* One tile with its ground out to every edge: what the installed app's
* maskable and iOS icons are drawn from (see icon.svg.twig). The browser
* draws it into PNGs; nothing shows it as it is.
*/
public function bleedUrl(LogoMotif $motif, ?LogoStyle $paint = null): string
{
return $this->generate('app_branding_bleed', $motif, $paint);
}
/**
* The install manifest for one choice, with the icons of the user whose
* key it carries, or the wax seal's static PNGs without one (a page nobody
* is signed in to).
*
* Same rule as the tab icon: a manifest is fetched without credentials, so
* the URL has to carry everything it depends on.
*/
public function manifestUrl(LogoMotif $motif, ?LogoStyle $paint = null, ?string $key = null): string
{
return $this->generate('app_branding_manifest', $motif, $paint, null === $key ? [] : ['u' => $key]);
}
/**
* One of a user's installed-app PNGs for one choice; $name is a key of
* AppIconStorage::ICONS (`tile-192`, `tile-512`, `bleed-512`, `bleed-180`).
*/
public function appIconUrl(string $name, string $key, LogoMotif $motif, ?LogoStyle $paint = null): string
{
return $this->generate('app_branding_app_icon', $motif, $paint, ['key' => $key, 'name' => $name]);
}
/**
* The top bar's logo for one choice: the glyph bare, for light chrome or
* for dark.
@@ -122,11 +157,11 @@ final class LogoIcons
}
/**
* The paint is the path's last segment, before `.svg`, and every query
* The paint is the path's last segment, before its extension, and every query
* parameter comes after it: the appearance pane builds each colourway's
* URL by swapping that one segment in the motif's own URL.
*
* @param array<string, int> $query
* @param array<string, int|string> $query
*/
private function generate(string $route, LogoMotif $motif, ?LogoStyle $paint, array $query = []): string
{
+3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Demo;
use App\Domain\Helper\AppIconStorage;
use App\Domain\Helper\AvatarStorage;
use App\Domain\Helper\SignatureStorage;
use App\Entity\User\User;
@@ -43,6 +44,7 @@ final readonly class DemoUserEraser
private DemoMode $demoMode,
private AvatarStorage $avatars,
private SignatureStorage $signatures,
private AppIconStorage $appIcons,
) {
}
@@ -86,6 +88,7 @@ final readonly class DemoUserEraser
// leaves an orphan nothing will ever find again.
$this->avatars->deleteAllFor((string) $user->id);
$this->signatures->deleteAllFor((string) $user->id);
$this->appIcons->deleteAllFor((string) $user->id);
$this->entityManager->remove($user);
$this->entityManager->flush();
+35
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Twig;
use App\Domain\Helper\AppIconStorage;
use App\Entity\Embeddable\Appearance;
use Symfony\Component\HttpFoundation\RequestStack;
use App\Infrastructure\Event\Subscriber\AppearanceCookieSubscriber;
@@ -34,6 +35,8 @@ final class AppearanceExtension extends AbstractExtension
// The icon URLs, spelled in one place with the drawing's version in
// them — see LogoIcons for why every one of them carries it.
private readonly LogoIcons $logoIcons,
// The installed app's PNGs, which the user's own browser draws (#141).
private readonly AppIconStorage $appIcons,
) {
}
@@ -50,6 +53,10 @@ final class AppearanceExtension extends AbstractExtension
new TwigFunction('logo_icon_url', $this->logoIcons->url(...)),
new TwigFunction('logo_favicon_url', $this->logoIcons->faviconUrl(...)),
new TwigFunction('logo_url', $this->logoIcons->logoUrl(...)),
new TwigFunction('logo_bleed_url', $this->logoIcons->bleedUrl(...)),
new TwigFunction('logo_manifest_url', $this->logoIcons->manifestUrl(...)),
new TwigFunction('logo_app_icon_url', $this->logoIcons->appIconUrl(...)),
new TwigFunction('app_icons', $this->appIcons(...)),
];
}
@@ -108,4 +115,32 @@ final class AppearanceExtension extends AbstractExtension
true === $user instanceof User ? $user->id : null,
);
}
/**
* The signed-in user's installed-app icons: the key their URLs carry, and
* whether the PNGs for the current choice still have to be drawn (a choice
* made before this existed, or a deploy that changed the drawing). Null
* for nobody: a page without a user links the static wax seal.
*
* @return array{key: string, missing: bool}|null
*/
public function appIcons(): ?array
{
$user = $this->security->getUser();
if (false === $user instanceof User) {
return null;
}
$appearance = $user->appearance;
return [
'key' => $this->appIcons->keyFor((string) $user->id),
'missing' => false === $this->appIcons->has(
(string) $user->id,
LogoIcons::choice($appearance->effectiveLogoMotif(), $appearance->effectiveLogoPaint()),
$this->logoIcons->version(),
),
];
}
}
+24 -3
View File
@@ -100,9 +100,19 @@
<meta name="title-count-key" content="{% block title_count_key %}role:inbox{% endblock %}">
{% include '_partials/_favicon.html.twig' %}
{# PWA: iOS only offers Web Push to apps added to the Home Screen, and it
needs the apple-touch-icon specifically — it ignores the manifest icons. #}
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
needs the apple-touch-icon specifically — it ignores the manifest icons.
Both follow the logo picked in Settings → Appearance (#141), as PNGs the
user's own browser drew (see AppIconStorage for why not the server). The
manifest is a route with the choice and the user's key in its URL, like
the tab icon: it is fetched without credentials. The apple-touch-icon is
the form that fills its square, because iOS rounds the corners itself
and paints a transparent one black. Nobody signed in: the wax seal. #}
{% set _app_icons = app_icons() %}
{% set _logo_motif = appearance().effectiveLogoMotif %}
{% set _logo_paint = appearance().effectiveLogoPaint %}
<link rel="manifest" href="{{ logo_manifest_url(_logo_motif, _logo_paint, _app_icons ? _app_icons.key : null) }}">
<link rel="apple-touch-icon" href="{{ _app_icons ? logo_app_icon_url('bleed-180', _app_icons.key, _logo_motif, _logo_paint) : '/icons/apple-touch-icon.png' }}">
{# The token the fetch-based endpoints check. Stimulus controllers that
build their own request body cannot carry a form field, so they read this
and send it as X-CSRF-Token. Every such endpoint validates against the
@@ -424,5 +434,16 @@
{% endfor %}
{% endfor %}
</div>
{# The installed app's PNGs for the current choice are not drawn yet: a logo
picked before #141, or a deploy that changed the drawing. This page draws
and uploads them once (core--app-icons); until then the icon route serves
the wax seal. #}
{% if _app_icons and _app_icons.missing %}
<div hidden
data-controller="core--app-icons"
data-core--app-icons-tile-url-value="{{ logo_icon_url(_logo_motif, _logo_paint) }}"
data-core--app-icons-bleed-url-value="{{ logo_bleed_url(_logo_motif, _logo_paint) }}"
data-core--app-icons-upload-url-value="{{ path('app_appearance_app_icons') }}"></div>
{% endif %}
</body>
</html>
+3 -3
View File
@@ -21,9 +21,9 @@
the smallest icon that is big enough for the tab, which is the 192px PNG.
A static file can only wear the product default, so every account saw the
wax seal in its tabs whatever icon it had chosen. A second candidate can
only be wrong for someone. The PNGs still serve the places that need a
raster and have no per-account route: the apple-touch-icon in app.html.twig
and the manifest's install icons.
only be wrong for someone. The PNGs that rasters need follow the choice
too: the install manifest's icons and the apple-touch-icon in app.html.twig
are the user's own, drawn by their browser (#141, AppIconStorage).
The SVG comes from a route rather than a file, because the logo is a
per-user setting now (Settings → Appearance → Logo: an icon, then its
+19 -1
View File
@@ -14,12 +14,24 @@
p the paints by part: '#rrggbb', 'url(#g)', 'url(#t)' or null
glyph_ramp seven stops when a glyph part is a sweep, else null
tile_ramp seven stops when the ground is one, else null
bleed true for the ground out to every edge of the 108 canvas, with
no rounded clip (see below), else the rounded tile
THE BLEED
A launcher or iOS cuts the corners itself: iOS rounds the square it is given
and paints a transparent corner black, and an Android maskable icon is masked
to whatever shape the launcher uses. Both want the ground to the edge, with
the glyph inside the safe circle, and the 108 canvas is exactly that: the
glyph sits in the middle 72, as it does in the tile, so the bleed is the same
picture with its edges let out. (It is the geometry of the fixed
apple-touch-icon.png and icon-maskable-512.png the installed app wore.)
Both gradients are userSpaceOnUse, so a ramp runs corner to corner across
the glyph — (8,6) to (40,42) on its grid — rather than restarting inside
every stroke it paints, and the ground's across the visible square.
#}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="18 18 72 72" width="72" height="72">
{% set bleed = bleed|default(false) %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="{{ bleed ? '0 0 108 108' : '18 18 72 72' }}" width="{{ bleed ? 108 : 72 }}" height="{{ bleed ? 108 : 72 }}">
<defs>
{% if glyph_ramp %}
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="8" y1="6" x2="40" y2="42">
@@ -31,9 +43,15 @@
{%- for stop in tile_ramp %}<stop offset="{{ '%.3f'|format(loop.index0 / 6) }}" stop-color="{{ stop }}"/>{% endfor -%}
</linearGradient>
{% endif %}
{% if not bleed %}
<clipPath id="tile"><rect x="18" y="18" width="72" height="72" rx="16"/></clipPath>
{% endif %}
</defs>
{% if bleed %}
<g>
{% else %}
<g clip-path="url(#tile)">
{% endif %}
<rect width="108" height="108" fill="{{ p.background }}"/>
<g transform="translate(25.2 25.2) scale(1.2)">{% include 'branding/motif/' ~ motif.value ~ '.svg.twig' with { p: p } only %}</g>
</g>
+6
View File
@@ -91,6 +91,7 @@
data-settings--appearance-upload-url-value="{{ path('app_appearance_background_upload') }}"
data-settings--appearance-import-url-value="{{ path('app_appearance_import') }}"
data-settings--appearance-reset-url-value="{{ path('app_appearance_reset') }}"
data-settings--appearance-app-icons-url-value="{{ path('app_appearance_app_icons') }}"
{# What a linked pl mark wears on a theme with no namesake colourway —
LogoStyle::DEFAULT, read here rather than spelled in the script. #}
data-settings--appearance-logo-default-value="{{ constant('App\\Domain\\Enum\\Theme\\LogoStyle::DEFAULT').value }}">
@@ -407,12 +408,17 @@
</span>
<div class="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-3"
role="group" aria-labelledby="logo-motif-label">
{# The installed app's URLs carry the user's key; see AppIconStorage. #}
{% set _app_icon_key = app_icons().key %}
{% for motif in enum('App\\Domain\\Enum\\Theme\\LogoMotif').cases %}
{% set _url = logo_icon_url(motif) %}
<button type="button"
data-logo-motif="{{ motif.value }}"
data-logo-icon="{{ _url }}"
data-logo-favicon="{{ logo_favicon_url(motif) }}"
data-logo-bleed="{{ logo_bleed_url(motif) }}"
data-logo-manifest="{{ logo_manifest_url(motif, null, _app_icon_key) }}"
data-logo-apple-touch="{{ logo_app_icon_url('bleed-180', _app_icon_key, motif) }}"
data-logo-bar="{{ logo_url(motif) }}"
data-logo-bar-dark="{{ logo_url(motif, null, true) }}"
data-action="click->settings--appearance#pickMotif"
+363
View File
@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Domain\Enum\Theme\LogoMotif;
use App\Domain\Enum\Theme\LogoStyle;
use App\Domain\Helper\AppIconStorage;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use App\Service\Appearance\LogoIcons;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* The installed app's PNGs: drawn by the user's browser, kept per user, and
* served to a manifest that is fetched without credentials (#141).
*
* The server never draws them (see AppIconStorage for why), so what is pinned
* here is what it does with somebody else's drawing: it takes only PNGs of
* exactly the right size, only for the uploader, and it never names an icon
* that is not there.
*/
final class AppIconsTest extends WebTestCase
{
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
private const string USER_EMAIL = 'e2e@plmail.test';
private const string UPLOAD = '/settings/appearance/app-icons';
private KernelBrowser $client;
private AppIconStorage $storage;
private string $scratch;
protected function setUp(): void
{
$this->client = static::createClient();
$this->client->disableReboot();
$this->storage = static::getContainer()->get(AppIconStorage::class);
$this->scratch = sys_get_temp_dir() . '/plmail-app-icons-' . bin2hex(random_bytes(4));
mkdir($this->scratch);
foreach ([self::ADMIN_EMAIL, self::USER_EMAIL] as $email) {
$this->storage->deleteAllFor((string) $this->user($email)->id);
}
}
protected function tearDown(): void
{
foreach ([self::ADMIN_EMAIL, self::USER_EMAIL] as $email) {
$user = $this->user($email);
$this->storage->deleteAllFor((string) $user->id);
$user->appearance->logoMotif = LogoMotif::DEFAULT;
$user->appearance->logoOriginal = true;
$user->appearance->logoStyle = LogoStyle::DEFAULT;
$user->appearance->logoLinked = true;
}
static::getContainer()->get(EntityManagerInterface::class)->flush();
array_map('unlink', glob($this->scratch . '/*') ?: []);
rmdir($this->scratch);
parent::tearDown();
}
public function testUploadedPngsAreWhatTheManifestsIconsServe(): void
{
$admin = $this->signIn(self::ADMIN_EMAIL);
$this->upload('blue-horn', 'ocean', $this->pngs(0x1d4ed8));
self::assertResponseIsSuccessful();
self::assertSame(['choice' => 'blue-horn.ocean'], json_decode((string) $this->client->getResponse()->getContent(), true));
$manifest = $this->manifestFor($admin, 'blue-horn', 'ocean');
foreach ($manifest['icons'] as $icon) {
$this->client->request('GET', $icon['src']);
self::assertResponseIsSuccessful();
self::assertSame('image/png', $this->client->getResponse()->headers->get('Content-Type'));
$cache = (string) $this->client->getResponse()->headers->get('Cache-Control');
self::assertStringContainsString('immutable', $cache, 'the user\'s own, at the current drawing, is kept');
self::assertStringNotContainsString('private', $cache);
$image = imagecreatefromstring((string) $this->client->getResponse()->getContent());
self::assertNotFalse($image);
self::assertSame(0x1d4ed8, imagecolorat($image, 5, 5) & 0xFFFFFF, 'the uploaded picture, not the wax seal');
}
}
/**
* A choice the browser has not drawn yet (picked before this existed, or a
* deploy changed the drawing): the wax seal of the same shape stands in, so
* a manifest never names a 404, and `no-cache` so it does not outlive the
* real one.
*/
public function testAMissingIconIsTheWaxSealAndNotKept(): void
{
$admin = $this->signIn(self::ADMIN_EMAIL);
$root = (string) static::getContainer()->getParameter('kernel.project_dir') . '/public/';
foreach (AppIconStorage::FALLBACK as $name => $file) {
$this->client->request('GET', $this->iconUrl($admin, $name, 'blue-horn', 'ocean'));
self::assertResponseIsSuccessful();
self::assertSame((string) file_get_contents($root . $file), (string) $this->client->getResponse()->getContent(), $name);
self::assertStringContainsString('no-cache', (string) $this->client->getResponse()->headers->get('Cache-Control'));
}
}
/** @return iterable<string, array{\Closure(self): array<string, string>}> */
public static function badUploads(): iterable
{
yield 'a size that is not the name\'s' => [static fn (self $t): array => ['tile-192' => $t->png(256, 0)] + $t->pngs(0)];
yield 'not a PNG' => [static fn (self $t): array => ['tile-512' => $t->jpeg(512)] + $t->pngs(0)];
yield 'not an image at all' => [static fn (self $t): array => ['bleed-180' => '<svg xmlns="http://www.w3.org/2000/svg"/>'] + $t->pngs(0)];
yield 'too big' => [static fn (self $t): array => ['tile-512' => $t->png(512, 0) . str_repeat("\0", AppIconStorage::MAX_BYTES)] + $t->pngs(0)];
yield 'one missing' => [static fn (self $t): array => array_diff_key($t->pngs(0), ['bleed-512' => true])];
}
/**
* @param \Closure(self): array<string, string> $files
*/
#[\PHPUnit\Framework\Attributes\DataProvider('badUploads')]
public function testOnlyPngsOfExactlyTheirSizeAreKept(\Closure $files): void
{
$admin = $this->signIn(self::ADMIN_EMAIL);
$this->upload('blue-horn', 'ocean', $files($this));
self::assertResponseStatusCodeSame(422);
self::assertFalse($this->storage->has((string) $admin->id, 'blue-horn.ocean', $this->version()), 'nothing half-stored');
}
public function testAChoiceTheRoutesCannotNameIsRefused(): void
{
$this->signIn(self::ADMIN_EMAIL);
$this->upload('chartreuse', 'original', $this->pngs(0));
self::assertResponseStatusCodeSame(422);
$this->upload('pl', 'chartreuse-dreams', $this->pngs(0));
self::assertResponseStatusCodeSame(422);
}
/** A page loaded before a deploy changed the drawing: its drawing is not this server's. */
public function testADrawingFromAnotherVersionIsRefused(): void
{
$this->signIn(self::ADMIN_EMAIL);
$this->upload('blue-horn', 'ocean', $this->pngs(0), version: '0123456789ab');
self::assertResponseStatusCodeSame(409);
}
public function testAnUploadNeedsTheToken(): void
{
$this->signIn(self::ADMIN_EMAIL);
$this->upload('blue-horn', 'ocean', $this->pngs(0), token: 'forged');
self::assertResponseStatusCodeSame(403);
}
public function testNobodySignedInCannotUpload(): void
{
$this->upload('blue-horn', 'ocean', $this->pngs(0), token: 'none');
self::assertResponseRedirects();
self::assertStringContainsString('/login', (string) $this->client->getResponse()->headers->get('Location'));
}
/** An upload is the uploader's: it changes nobody else's icons. */
public function testAnUploadOnlyEverChangesTheUploadersIcons(): void
{
$other = $this->user(self::USER_EMAIL);
$this->storage->store((string) $other->id, 'blue-horn.ocean', $this->version(), $this->pngs(0x00ff00));
$this->signIn(self::ADMIN_EMAIL);
$this->upload('blue-horn', 'ocean', $this->pngs(0xff0000));
self::assertResponseIsSuccessful();
$this->client->request('GET', $this->iconUrl($other, 'tile-192', 'blue-horn', 'ocean'));
$image = imagecreatefromstring((string) $this->client->getResponse()->getContent());
self::assertNotFalse($image);
self::assertSame(0x00ff00, imagecolorat($image, 5, 5) & 0xFFFFFF);
self::assertNotSame(
$this->storage->keyFor((string) $other->id),
$this->storage->keyFor((string) $this->user(self::ADMIN_EMAIL)->id),
);
}
/** Only the current choice is ever linked, so only it is kept. */
public function testANewChoiceReplacesTheOldOne(): void
{
$admin = $this->signIn(self::ADMIN_EMAIL);
$this->upload('blue-horn', 'ocean', $this->pngs(0));
$this->upload('pl', 'postal', $this->pngs(0));
self::assertTrue($this->storage->has((string) $admin->id, 'pl.postal', $this->version()));
self::assertFalse($this->storage->has((string) $admin->id, 'blue-horn.ocean', $this->version()));
}
/**
* The page draws them only when they are missing: the layout carries the
* element that does it until an upload for the current choice has landed.
*/
public function testThePageAsksForTheIconsOnlyWhileTheyAreMissing(): void
{
$admin = $this->signIn(self::ADMIN_EMAIL);
$admin->appearance->logoMotif = LogoMotif::BlueHorn;
$admin->appearance->logoOriginal = true;
static::getContainer()->get(EntityManagerInterface::class)->flush();
$drawer = $this->client->request('GET', '/mail/inbox')->filter('[data-controller="core--app-icons"]');
self::assertCount(1, $drawer);
self::assertStringStartsWith('/branding/icon/blue-horn/original.svg?v=', (string) $drawer->attr('data-core--app-icons-tile-url-value'));
self::assertStringStartsWith('/branding/bleed/blue-horn/original.svg?v=', (string) $drawer->attr('data-core--app-icons-bleed-url-value'));
$this->upload('blue-horn', 'original', $this->pngs(0));
self::assertCount(0, $this->client->request('GET', '/mail/inbox')->filter('[data-controller="core--app-icons"]'));
}
/**
* The bleed is the tile with its edges let out: the 108 canvas, no rounded
* clip, so iOS and a maskable icon can cut their own corners.
*/
public function testTheBleedSvgIsTheWholeCanvasWithoutTheRoundedClip(): void
{
$this->client->request('GET', '/branding/bleed/wax-seal/original.svg');
$bleed = (string) $this->client->getResponse()->getContent();
$this->client->request('GET', '/branding/icon/wax-seal/original.svg');
$tile = (string) $this->client->getResponse()->getContent();
self::assertStringContainsString('viewBox="0 0 108 108"', $bleed);
self::assertStringNotContainsString('clip-path', $bleed);
// The companion: the tile still is the rounded one.
self::assertStringContainsString('viewBox="18 18 72 72"', $tile);
self::assertStringContainsString('clip-path="url(#tile)"', $tile);
}
/** @return array<string, string> a valid set, every name at its size, filled with $rgb */
public function pngs(int $rgb): array
{
$pngs = [];
foreach (AppIconStorage::ICONS as $name => $size) {
$pngs[$name] = $this->png($size, $rgb);
}
return $pngs;
}
public function png(int $size, int $rgb): string
{
$image = imagecreatetruecolor($size, $size);
imagefill($image, 0, 0, $rgb);
ob_start();
imagepng($image);
return (string) ob_get_clean();
}
public function jpeg(int $size): string
{
ob_start();
imagejpeg(imagecreatetruecolor($size, $size));
return (string) ob_get_clean();
}
/**
* @param array<string, string> $pngs name => bytes
*/
private function upload(string $motif, string $paint, array $pngs, ?string $version = null, ?string $token = null): void
{
$files = [];
foreach ($pngs as $name => $bytes) {
$path = $this->scratch . '/' . $name . '-' . bin2hex(random_bytes(3)) . '.png';
file_put_contents($path, $bytes);
$files[$name] = new UploadedFile($path, $name . '.png', 'image/png', test: true);
}
if (null === $token) {
// The layout's `ajax` token, as the page sends it.
$token = (string) $this->client->request('GET', '/settings?section=appearance')
->filter('meta[name="csrf-token"]')->attr('content');
}
$this->client->request(
'POST',
self::UPLOAD,
['motif' => $motif, 'paint' => $paint, 'version' => $version ?? $this->version()],
$files,
['HTTP_X_CSRF_TOKEN' => $token],
);
}
/** @return array{icons: list<array{src: string}>} */
private function manifestFor(User $user, string $motif, string $paint): array
{
$this->client->request('GET', sprintf(
'/branding/manifest/%s/%s.webmanifest?u=%s',
$motif,
$paint,
$this->storage->keyFor((string) $user->id),
));
self::assertResponseIsSuccessful();
/** @var array{icons: list<array{src: string}>} */
return json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
}
private function iconUrl(User $user, string $name, string $motif, string $paint): string
{
return static::getContainer()->get(LogoIcons::class)->appIconUrl(
$name,
$this->storage->keyFor((string) $user->id),
LogoMotif::from($motif),
LogoStyle::tryFrom($paint),
);
}
private function version(): string
{
return static::getContainer()->get(LogoIcons::class)->version();
}
private function signIn(string $email): User
{
$user = $this->user($email);
$this->client->loginUser($user);
return $user;
}
private function user(string $email): User
{
$user = static::getContainer()->get(UserRepository::class)->findOneBy(['email' => $email]);
if (false === $user instanceof User) {
self::markTestSkipped('run `app:test:seed-user` and `app:test:seed-user --admin` first');
}
return $user;
}
}
+326
View File
@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Domain\Enum\Theme\LogoMotif;
use App\Domain\Enum\Theme\LogoStyle;
use App\Domain\Helper\AppIconStorage;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use App\Service\Appearance\LogoIcons;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* The installed app wears the icon the person picked (#141).
*
* The tab icon followed the choice (BrandingFaviconTest) while the install
* icon stayed the wax seal: a PWA's icon comes from its manifest, and the
* manifest was one static file. It is a route now, with the choice in the URL
* like every other picture of the logo, and the route draws what the URL names
* and never what the session holds: a manifest is fetched without
* credentials, so a session could not speak for it even if it were allowed to.
* The user's key in the URL says whose PNGs it names (AppIconStorage).
*
* From #92 by @Flouss, adapted to PNGs the browser draws.
*/
final class BrandingManifestTest extends WebTestCase
{
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
protected function tearDown(): void
{
if (null !== $user = $this->find()) {
$user->appearance->logoMotif = LogoMotif::DEFAULT;
$user->appearance->logoOriginal = true;
$user->appearance->logoStyle = LogoStyle::DEFAULT;
$user->appearance->logoLinked = true;
static::getContainer()->get(EntityManagerInterface::class)->flush();
}
parent::tearDown();
}
public function testAnAnonymousPageLinksTheDefaultManifestWithoutAKey(): void
{
$client = static::createClient();
$href = $this->manifestLink($client, '/login');
// Hand-spelled: the default is the wax seal in its own design, and the
// route's path is `<motif>/<paint>`.
self::assertStringStartsWith('/branding/manifest/wax-seal/original.webmanifest?v=', $href);
self::assertStringNotContainsString('u=', $href);
}
public function testAPageLinksTheManifestOfTheUsersChoiceAndKey(): void
{
[$client, $user] = $this->signedIn();
$user->appearance->logoMotif = LogoMotif::BlueHorn;
$user->appearance->logoOriginal = true;
static::getContainer()->get(EntityManagerInterface::class)->flush();
$href = $this->manifestLink($client, '/mail/inbox');
self::assertStringStartsWith('/branding/manifest/blue-horn/original.webmanifest?u=' . $this->key($user) . '&v=', $href);
// The paint is part of the choice, so a recolour is a new URL, which is
// what makes the browser look again.
$user->appearance->logoMotif = LogoMotif::Pl;
$user->appearance->logoStyle = LogoStyle::Postal;
$user->appearance->logoLinked = false;
static::getContainer()->get(EntityManagerInterface::class)->flush();
self::assertStringStartsWith('/branding/manifest/pl/postal.webmanifest?u=', $this->manifestLink($client, '/mail/inbox'));
}
/**
* The point of the route: the installed app's icons are the choice's, as
* the user's PNGs. The rounded tile at the two sizes a manifest is
* expected to carry, and the ground-to-the-edge form as the maskable icon.
* One picture per purpose and size: a static candidate beside them is how
* the wax seal outvoted the tab icon in Firefox.
*/
public function testWithAKeyTheInstallIconsAreTheUsersPngsOfTheChoice(): void
{
$client = static::createClient();
$key = str_repeat('ab', 10);
$manifest = $this->manifest($client, '/branding/manifest/blue-horn/ocean.webmanifest?u=' . $key);
// The presence companion: no icons at all would pass the loop below.
self::assertCount(3, $manifest['icons'], 'a tile at 192 and 512, and the maskable 512');
$expected = [
['tile-192', '192x192', 'any'],
['tile-512', '512x512', 'any'],
['bleed-512', '512x512', 'maskable'],
];
foreach ($expected as $i => [$name, $sizes, $purpose]) {
$icon = $manifest['icons'][$i];
self::assertSame('image/png', $icon['type']);
self::assertSame($sizes, $icon['sizes']);
self::assertSame($purpose, $icon['purpose']);
self::assertStringStartsWith(sprintf('/branding/app-icon/%s/%s/blue-horn/ocean.png?v=', $key, $name), $icon['src']);
}
}
/** Nobody signed in: the static file's own wax seal, as it always was. */
public function testWithoutAKeyTheInstallIconsAreTheStaticFilesOwn(): void
{
$client = static::createClient();
$manifest = $this->manifest($client, '/branding/manifest/wax-seal/original.webmanifest');
self::assertSame($this->staticManifest()['icons'], $manifest['icons']);
}
/**
* iOS ignores the manifest for its home-screen icon and reads the page's
* apple-touch-icon, which has to be a PNG. iOS rounds the corners itself and
* paints a transparent one black, hence the form that fills the square.
*/
public function testTheHomeScreenIconOfIosIsTheUsersBleedPng(): void
{
[$client, $user] = $this->signedIn();
$user->appearance->logoMotif = LogoMotif::BlueHorn;
$user->appearance->logoOriginal = true;
static::getContainer()->get(EntityManagerInterface::class)->flush();
$link = $client->request('GET', '/mail/inbox')->filter('link[rel="apple-touch-icon"]');
self::assertCount(1, $link);
self::assertStringStartsWith(
sprintf('/branding/app-icon/%s/bleed-180/blue-horn/original.png?v=', $this->key($user)),
(string) $link->attr('href'),
);
}
public function testAnAnonymousPageLinksTheStaticHomeScreenIcon(): void
{
$link = static::createClient()->request('GET', '/login')->filter('link[rel="apple-touch-icon"]');
self::assertCount(1, $link);
self::assertSame('/icons/apple-touch-icon.png', $link->attr('href'));
}
/** Everything but the icons is the static manifest's, so the two cannot drift. */
public function testTheRestOfTheManifestIsTheStaticFilesOwn(): void
{
$client = static::createClient();
$static = $this->staticManifest();
$dynamic = $this->manifest($client, '/branding/manifest/wax-seal/original.webmanifest?u=' . str_repeat('ab', 10));
unset($static['icons'], $dynamic['icons']);
// Presence companion: an empty static file would equal an empty route.
self::assertSame('/mail/inbox', $static['start_url']);
self::assertSame($static, $dynamic);
}
/**
* Drawn from the URL, not the session: the manifest is fetched without
* credentials, and the appearance pane re-points it before the save lands.
*/
public function testTheManifestNamesWhatItsUrlNamesNotWhatTheSessionHolds(): void
{
[$client, $user] = $this->signedIn();
$user->appearance->logoMotif = LogoMotif::Pl;
$user->appearance->logoStyle = LogoStyle::Postal;
$user->appearance->logoLinked = false;
static::getContainer()->get(EntityManagerInterface::class)->flush();
$manifest = $this->manifest($client, '/branding/manifest/blue-horn/original.webmanifest?u=' . $this->key($user));
self::assertCount(3, $manifest['icons']);
foreach ($manifest['icons'] as $icon) {
self::assertStringContainsString('/blue-horn/original.png', $icon['src']);
}
}
/**
* The manifest route promises its URL is immutable, so a change to the file
* it serves has to move the URL, or every browser keeps the old app name or
* colour for a year.
*/
public function testEditingTheStaticManifestMovesTheVersion(): void
{
self::bootKernel();
$dir = sys_get_temp_dir() . '/plmail-manifest-' . bin2hex(random_bytes(4));
mkdir($dir . '/public', recursive: true);
$versionWith = static function (string $manifest) use ($dir): string {
file_put_contents($dir . '/public/manifest.webmanifest', $manifest);
// A fresh instance each time: the version is memoised per object.
return new LogoIcons(static::getContainer()->get(UrlGeneratorInterface::class), $dir)->version();
};
try {
$first = $versionWith('{"name":"plMail"}');
// The stable half, so "always different" would not pass.
self::assertSame($first, $versionWith('{"name":"plMail"}'));
self::assertNotSame($first, $versionWith('{"name":"plMail2"}'));
} finally {
@unlink($dir . '/public/manifest.webmanifest');
@rmdir($dir . '/public');
@rmdir($dir);
}
}
public function testTheManifestIsPublicAndTheRightType(): void
{
$client = static::createClient();
$client->request('GET', '/branding/manifest/wax-seal/original.webmanifest');
self::assertResponseIsSuccessful();
self::assertSame('application/manifest+json', $client->getResponse()->headers->get('Content-Type'));
self::assertStringNotContainsString('private', (string) $client->getResponse()->headers->get('Cache-Control'));
self::assertTrue($client->getResponse()->headers->has('ETag'));
}
/** Two users' manifests for one choice are two documents, so one ETag must not answer for both. */
public function testTheKeyIsPartOfTheManifestsEtag(): void
{
$client = static::createClient();
$client->request('GET', '/branding/manifest/wax-seal/original.webmanifest?u=' . str_repeat('ab', 10));
$first = $client->getResponse()->headers->get('ETag');
$client->request('GET', '/branding/manifest/wax-seal/original.webmanifest?u=' . str_repeat('cd', 10));
self::assertNotSame($first, $client->getResponse()->headers->get('ETag'));
}
public function testAnUnknownChoiceOrAMalformedKeyHasNoManifest(): void
{
$client = static::createClient();
foreach ([
'/branding/manifest/chartreuse/original.webmanifest',
'/branding/manifest/pl/chartreuse-dreams.webmanifest',
'/branding/manifest/pl/original.webmanifest?u=../../etc',
'/branding/manifest/pl/original.webmanifest?u=ABABABABABABABABABAB',
] as $url) {
$client->request('GET', $url);
self::assertResponseStatusCodeSame(404, $url);
}
}
/** The manifest a page links, as the href the browser would fetch. */
private function manifestLink(KernelBrowser $client, string $page): string
{
$link = $client->request('GET', $page)->filter('link[rel="manifest"]');
// Presence companion: a page with no manifest link at all would pass
// any "does not link the static file" check.
self::assertCount(1, $link, 'exactly one manifest is linked');
return (string) $link->attr('href');
}
/** @return array{icons: list<array{src: string, sizes: string, type: string, purpose: string}>, start_url: string} */
private function manifest(KernelBrowser $client, string $url): array
{
$client->request('GET', $url);
self::assertResponseIsSuccessful();
/** @var array{icons: list<array{src: string, sizes: string, type: string, purpose: string}>, start_url: string} */
return json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
}
/** @return array<string, mixed> */
private function staticManifest(): array
{
/** @var array<string, mixed> */
return json_decode(
(string) file_get_contents(self::getContainer()->getParameter('kernel.project_dir') . '/public/manifest.webmanifest'),
true,
flags: JSON_THROW_ON_ERROR,
);
}
private function key(User $user): string
{
return static::getContainer()->get(AppIconStorage::class)->keyFor((string) $user->id);
}
/** @return array{KernelBrowser, User} */
private function signedIn(): array
{
$client = static::createClient();
$user = $this->find();
if (null === $user) {
self::markTestSkipped('run `app:test:seed-user --admin` first');
}
$client->loginUser($user);
return [$client, $user];
}
private function find(): ?User
{
$user = static::getContainer()->get(UserRepository::class)
->findOneBy(['email' => self::ADMIN_EMAIL]);
return $user instanceof User ? $user : null;
}
}
+129
View File
@@ -0,0 +1,129 @@
import { test, expect, type Page } from "../support/test";
import { ajaxPost } from "../support/config";
/**
* The installed app's icons follow the logo (#141).
*
* The server never draws these PNGs: the browser draws the chosen icon on a
* canvas and uploads it (assets/app_icons.js), so only a real browser can show
* the whole path works: a pick draws four PNGs of the right sizes, the server
* keeps them, and the manifest and iOS icon the page links serve them instead
* of the wax seal.
*
* "Serves them" is told apart from the wax seal by the cache header: the
* user's own PNG is immutable at the current drawing, the stand-in is
* `no-cache` (BrandingController::appIcon()).
*/
const PANEL = '[data-controller="settings--appearance"]';
const UPLOAD = "/settings/appearance/app-icons";
/**
* The upload of one choice, as the server names it (`<motif>.<paint>`). Not
* just any upload: opening a page can draw the user's current choice too, when
* it is missing, and that one must not be mistaken for the pick's. Told apart
* by the answer, because Chromium does not expose a multipart body with files
* to the test.
*/
function uploadOf(page: Page, choice: string) {
return page.waitForResponse(async (r) => {
if (!r.url().endsWith(UPLOAD) || r.request().method() !== "POST") {
return false;
}
try {
return (await r.json()).choice === choice;
} catch {
return false;
}
});
}
/** Width and height from a PNG's IHDR chunk, which always starts at byte 16. */
function pngSize(bytes: Buffer): [number, number] {
expect(bytes.subarray(1, 4).toString("latin1"), "a PNG").toBe("PNG");
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
}
async function linked(page: Page, rel: string): Promise<string> {
const href = await page.locator(`link[rel="${rel}"]`).getAttribute("href");
expect(href, `the page links a ${rel}`).toBeTruthy();
return href as string;
}
/** The user's own PNG at that URL, of that size. */
async function expectOwnIcon(page: Page, url: string, size: number): Promise<void> {
const response = await page.request.get(url);
expect(response.status(), url).toBe(200);
expect(response.headers()["content-type"]).toBe("image/png");
expect(response.headers()["cache-control"], `${url} is the user's own, not the wax seal`).toContain("immutable");
expect(pngSize(await response.body())).toEqual([size, size]);
}
/** Back to the default logo, which is where both tests start and must leave the user. */
async function reset(page: Page): Promise<void> {
await page.goto("/settings?section=appearance");
await page.locator(PANEL).getByRole("button", { name: /reset/i }).first().click();
await page.waitForResponse((r) => r.url().includes("/appearance/reset")).catch(() => {});
}
test.describe("appearance — the installed app's icons", () => {
// Before as well as after: picking the logo that is already chosen is not
// a change and draws nothing, so the test must not inherit a blue horn.
test.beforeEach(async ({ page }) => reset(page));
// The logo is this worker's user's, and the rest of the suite reads it.
test.afterEach(async ({ page }) => reset(page));
test("picking a logo draws its PNGs, and the manifest and iOS icon serve them", async ({ page }) => {
await page.goto("/settings?section=appearance");
await expect(page.locator(PANEL)).toBeVisible();
const uploaded = uploadOf(page, "blue-horn.original");
await page.locator(`${PANEL} [data-logo-motif="blue-horn"]`).click();
expect((await uploaded).status(), "the server kept all four").toBe(200);
// Re-pointed on the pick, before any reload.
const manifestUrl = await linked(page, "manifest");
expect(manifestUrl).toContain("/branding/manifest/blue-horn/original.webmanifest?u=");
const manifest = await (await page.request.get(manifestUrl)).json();
const icons = manifest.icons as { src: string; sizes: string; purpose: string }[];
expect(icons.map((icon) => [icon.sizes, icon.purpose])).toEqual([
["192x192", "any"],
["512x512", "any"],
["512x512", "maskable"],
]);
for (const icon of icons) {
await expectOwnIcon(page, icon.src, Number(icon.sizes.split("x")[0]));
}
const appleTouch = await linked(page, "apple-touch-icon");
expect(appleTouch).toContain("/bleed-180/blue-horn/original.png");
await expectOwnIcon(page, appleTouch, 180);
});
test("a page whose PNGs are missing draws them once", async ({ page }) => {
// Saved without the pane, so nothing has drawn this choice: only one
// choice is kept per user, and the last one drawn was not this.
await page.goto("/settings?section=appearance");
const saved = await ajaxPost(page, "/settings/appearance", { logoMotif: "pl", logoOriginal: "0", logoLinked: "0", logoStyle: "postal" });
expect(saved.ok()).toBe(true);
const uploaded = uploadOf(page, "pl.postal");
await page.goto("/mail/inbox");
expect((await uploaded).status()).toBe(200);
await expectOwnIcon(page, await linked(page, "apple-touch-icon"), 180);
// Drawn now, so the next page does not ask again.
await page.goto("/mail/inbox");
await expect(page.locator('[data-controller="core--app-icons"]')).toHaveCount(0);
});
});