mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat: save a signature once instead of drawing it on every document
Settings → Profile gains a pad beside the avatar. Draw a signature there and the PDF reader offers "Use saved signature", which places it without asking again; remove it from the same place. It goes through exactly the same placement path as a fresh scribble, because a stamp has been an IMAGE rather than a set of strokes since the day signing was written. That was the point of expressing it that way: this is six lines, not a second implementation to keep in step. The two pads share trimAlpha from assets/pdf_geometry.js for the same reason — they have to agree about what a stamp is. Deliberately NOT the "Signature" section in the sidebar. That one is the block of text appended to outgoing mail; the two are unrelated and the wording keeps them apart. SignatureStorage is AvatarStorage's shape — one file per user under var/uploads, replaced in place, random filename so a changed image is not served from cache. PNG only, and that is a requirement rather than a narrow allow-list: the image is stamped onto somebody else's document and anything without alpha lands as a white card over their text. It is not a credential. There is nothing to verify against it and it is encrypted by nothing, which is the honest treatment for a picture of a squiggle. FILES OUTLIVE ROWS Erasing a demo visitor now deletes their avatar and signature too. DemoUserEraser finds what to delete from Doctrine's metadata, and a PNG under var/uploads is not a row — so on a public demo minting a throwaway user per visitor, every signature drawn was staying on disk for good. TWO BUGS THE TESTS HID The save endpoint redirected to a route named `app_settings`, which does not exist — every POST was a 500. Three of the four tests passed anyway, satisfied by a signature an earlier failed run had left on disk. They clear any saved signature in beforeEach now, so "it saved" cannot pass without anything being saved. And the first version could not draw at all: the pad sits below the fold, page.mouse works in viewport coordinates, and boundingBox() happily reports a box off-screen. The strokes landed nowhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74dd48df33
commit
e00fe25a14
18 files changed
+777
-2
No files matched your search
@@ -50,6 +50,7 @@ import { pdfRect, stampAnchor, trimAlpha } from "../../pdf_geometry.js";
|
||||
* | filename | what the signed copy is called, for the download only |
|
||||
* | encryptedText | why signing is unavailable on a protected document |
|
||||
* | unreadableText | and why it is unavailable on one that will not parse |
|
||||
* | savedUrl | the saved signature, when there is one — empty when not |
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["pad", "ink", "stamp", "sheet", "reason", "apply", "form", "file", "frameField"];
|
||||
@@ -60,6 +61,7 @@ export default class extends Controller {
|
||||
filename: String,
|
||||
encryptedText: String,
|
||||
unreadableText: String,
|
||||
savedUrl: String,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -162,6 +164,48 @@ export default class extends Controller {
|
||||
this.applyTarget.disabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the signature saved in Settings, without drawing anything.
|
||||
*
|
||||
* Goes through exactly the same path as a fresh scribble, because a stamp
|
||||
* is an IMAGE from the moment it is captured rather than a set of strokes.
|
||||
* That was the point of expressing it that way: this method is six lines
|
||||
* instead of a second placement implementation to keep in step.
|
||||
*/
|
||||
async useSaved(event) {
|
||||
event?.preventDefault();
|
||||
|
||||
const refusal = await this.#refusal();
|
||||
|
||||
if (null !== refusal) {
|
||||
this.reasonTarget.textContent = refusal;
|
||||
this.reasonTarget.hidden = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(this.savedUrlValue, { credentials: "same-origin" });
|
||||
|
||||
if (false === response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
|
||||
this._stamp = {
|
||||
bytes: await blob.arrayBuffer(),
|
||||
url: URL.createObjectURL(blob),
|
||||
ratio: bitmap.width / bitmap.height,
|
||||
};
|
||||
|
||||
bitmap.close();
|
||||
this.reasonTarget.hidden = true;
|
||||
this.padTarget.hidden = true;
|
||||
|
||||
this.#placeOnCurrentPage();
|
||||
}
|
||||
|
||||
/** Turn the ink into a stamp and put it on the page to be positioned. */
|
||||
async apply(event) {
|
||||
event?.preventDefault();
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { trimAlpha } from "../../pdf_geometry.js";
|
||||
|
||||
/**
|
||||
* Drawing a signature once, in Settings, so it need not be drawn again.
|
||||
*
|
||||
* The pad itself, and nothing else: the file is posted by an ordinary form, so
|
||||
* saving works the way every other setting on this page does and there is no
|
||||
* fetch() here to keep in step with a CSRF token.
|
||||
*
|
||||
* WHAT IT PRODUCES
|
||||
*
|
||||
* A trimmed PNG with a transparent background, drawn at four times the size it
|
||||
* is shown at. All three properties matter and each is a bug if dropped.
|
||||
* Transparency, because the image is stamped onto somebody else's document and
|
||||
* an opaque one is a white card over their text. Trimmed, because otherwise the
|
||||
* mark sits wherever inside the placement box it happened to be drawn rather
|
||||
* than where the box was put. Supersampled, because a stamp captured at 1×
|
||||
* looks right on screen and prints as a smear — and the document people most
|
||||
* want a signature on is the one they are going to print.
|
||||
*
|
||||
* trimAlpha comes from assets/pdf_geometry.js, which is where the signing
|
||||
* controller gets it too. The two pads have to agree about what a stamp IS, and
|
||||
* one copy of the trim is how that stays true.
|
||||
*
|
||||
* | Targets | |
|
||||
* |---------|--------------------------------------------------------------|
|
||||
* | ink | the canvas drawn on |
|
||||
* | file | the file input the form posts |
|
||||
* | save | disabled until something has actually been drawn |
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["ink", "file", "save"];
|
||||
|
||||
/** Matches mail--pdf-sign, deliberately: same stamp, same resolution. */
|
||||
static SUPERSAMPLE = 4;
|
||||
|
||||
connect() {
|
||||
this._drawing = false;
|
||||
this.#prepare();
|
||||
|
||||
// The pad has no size until it is laid out, and a canvas sized from a
|
||||
// zero-width box stays zero-width for good. This is the settings pane,
|
||||
// which can be rendered into a hidden section and shown afterwards.
|
||||
this._sizes = new ResizeObserver(() => this.#prepareIfResized());
|
||||
this._sizes.observe(this.inkTarget);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._sizes?.disconnect();
|
||||
}
|
||||
|
||||
startStroke(event) {
|
||||
event.preventDefault();
|
||||
|
||||
this._drawing = true;
|
||||
this.inkTarget.setPointerCapture?.(event.pointerId);
|
||||
|
||||
const context = this.inkTarget.getContext("2d");
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(...this.#point(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesced events, because a quick signature moves further between frames
|
||||
* than between input samples and a line drawn frame to frame is visibly
|
||||
* polygonal.
|
||||
*/
|
||||
stroke(event) {
|
||||
if (false === this._drawing) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const context = this.inkTarget.getContext("2d");
|
||||
|
||||
for (const point of event.getCoalescedEvents?.() ?? [event]) {
|
||||
context.lineTo(...this.#point(point));
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
async endStroke(event) {
|
||||
if (false === this._drawing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._drawing = false;
|
||||
this.inkTarget.releasePointerCapture?.(event.pointerId);
|
||||
|
||||
await this.#capture();
|
||||
}
|
||||
|
||||
clear(event) {
|
||||
event?.preventDefault();
|
||||
|
||||
this.#prepare();
|
||||
this.fileTarget.value = "";
|
||||
this.saveTarget.disabled = true;
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────
|
||||
|
||||
#prepareIfResized() {
|
||||
const box = this.inkTarget.getBoundingClientRect();
|
||||
const wanted = Math.round(box.width * this.constructor.SUPERSAMPLE);
|
||||
|
||||
// Only when the width actually changed: preparing resets the canvas,
|
||||
// and a ResizeObserver that fires for any reason would wipe a signature
|
||||
// somebody was half-way through drawing.
|
||||
if (0 !== wanted && wanted !== this.inkTarget.width) {
|
||||
this.#prepare();
|
||||
}
|
||||
}
|
||||
|
||||
#prepare() {
|
||||
const canvas = this.inkTarget;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
const scale = this.constructor.SUPERSAMPLE;
|
||||
|
||||
canvas.width = Math.round(box.width * scale);
|
||||
canvas.height = Math.round(box.height * scale);
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.lineWidth = 2.5 * scale;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
// Ink, not the theme's accent: this goes onto other people's documents,
|
||||
// where the reader's chosen colour means nothing.
|
||||
context.strokeStyle = "#111827";
|
||||
}
|
||||
|
||||
#point(event) {
|
||||
const box = this.inkTarget.getBoundingClientRect();
|
||||
const scale = this.inkTarget.width / box.width;
|
||||
|
||||
return [(event.clientX - box.left) * scale, (event.clientY - box.top) * scale];
|
||||
}
|
||||
|
||||
/** Trim to the stroke, and hand the result to the form as a real file. */
|
||||
async #capture() {
|
||||
const canvas = this.inkTarget;
|
||||
const context = canvas.getContext("2d");
|
||||
const bounds = trimAlpha(context.getImageData(0, 0, canvas.width, canvas.height));
|
||||
|
||||
if (null === bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cut = document.createElement("canvas");
|
||||
|
||||
cut.width = bounds.width;
|
||||
cut.height = bounds.height;
|
||||
cut.getContext("2d").drawImage(
|
||||
canvas,
|
||||
bounds.left, bounds.top, bounds.width, bounds.height,
|
||||
0, 0, bounds.width, bounds.height,
|
||||
);
|
||||
|
||||
const blob = await new Promise((resolve) => cut.toBlob(resolve, "image/png"));
|
||||
const transfer = new DataTransfer();
|
||||
|
||||
transfer.items.add(new File([blob], "signature.png", { type: "image/png" }));
|
||||
this.fileTarget.files = transfer.files;
|
||||
this.saveTarget.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,10 @@ services:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$storageDir: '%env(APP_STORAGE_DIR)%'
|
||||
App\Domain\Helper\SignatureStorage:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$storageDir: '%env(APP_STORAGE_DIR)%'
|
||||
App\Command\Maintenance\PruneBlobsCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: features/mail.md sha1:fd73344f9fa8c31bd3c411e40dcc73bd156c21b8 -->
|
||||
<!-- translated-from: features/mail.md sha1:379d57cb5ef3c1b66d58d115dfcffc017b92f280 -->
|
||||
|
||||
# Mail
|
||||
|
||||
@@ -361,6 +361,13 @@ nicht die richtige.
|
||||
Rechner, und plMails Server sieht das Ergebnis nur, wenn du damit antwortest. Nichts geht an einen
|
||||
Signaturdienst, weil es keinen gibt.
|
||||
|
||||
**Einmal speichern statt jedes Mal neu zeichnen.** Unter Einstellungen → Profil gibt es neben deinem
|
||||
Bild ein Feld zum Zeichnen; zeichne deine Unterschrift dort, und der Leser bekommt einen Knopf
|
||||
**Gespeicherte Unterschrift verwenden**, der sie setzt, ohne dass du sie neu zeichnen musst. Sie
|
||||
wird als Bild gespeichert, gehört dir, wird nur an dich ausgeliefert, und du kannst sie an derselben
|
||||
Stelle wieder entfernen. Was die Unterschrift *bedeutet*, ändert das nicht — es ist so oder so
|
||||
dasselbe Bild eines Namens.
|
||||
|
||||
Ein passwortgeschütztes PDF lässt sich hier nicht unterschreiben — der Leser sagt das, und lesen
|
||||
funktioniert weiterhin. Es zu bearbeiten hieße, den Schutz zu entfernen, den es bekommen hat, und
|
||||
das ist keine Entscheidung, die plMail still für dich treffen sollte.
|
||||
|
||||
@@ -324,6 +324,12 @@ been asked for a *qualified* electronic signature, this is not it.
|
||||
plMail's server only sees the result if you choose to reply with it. Nothing is uploaded to a
|
||||
signing service, because there is no signing service.
|
||||
|
||||
**Save it once instead of drawing it every time.** Settings → Profile has a pad beside your picture;
|
||||
draw your signature there and the reader gains a **Use saved signature** button that places it
|
||||
without asking you to draw again. It is stored as an image belonging to you, served only to you, and
|
||||
you can remove it from the same place. It changes nothing about what the signature *means* — it is
|
||||
the same picture of a name either way.
|
||||
|
||||
A PDF that is password-protected cannot be signed here — the reader says so, and reading it still
|
||||
works. Editing one would mean stripping the protection it was given, which is not a decision plMail
|
||||
should make quietly on your behalf.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* A place to remember a saved signature, for stamping onto PDFs.
|
||||
*
|
||||
* The filename only. The image itself lives under var/uploads next to the
|
||||
* avatars, because a PNG has no business in a row read on every request.
|
||||
*
|
||||
* Additive and nullable, so it costs nothing on an existing install and the
|
||||
* down migration loses only the pointer — the files stay on disk, which is the
|
||||
* safe direction for a rollback.
|
||||
*/
|
||||
final class Version20260825140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add user.signature, the filename of a saved handwritten signature.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" ADD signature VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" DROP signature');
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,26 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Settings;
|
||||
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Domain\Helper\AvatarStorage;
|
||||
use App\Domain\Helper\SignatureStorage;
|
||||
use App\Entity\User\User;
|
||||
use App\Form\User\ProfileType;
|
||||
use App\Service\User\ProfileSectionViewData;
|
||||
use App\Service\User\ProfileUpdater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Name and avatar, outside the setup wizard.
|
||||
* Name, avatar and a saved handwritten signature, outside the setup wizard.
|
||||
*
|
||||
* The wizard's profile step is a second view onto this, not the other way
|
||||
* round: a setting reachable only from a wizard that opens once is a setting
|
||||
@@ -28,6 +33,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('ROLE_USER')]
|
||||
final class ProfileController extends AbstractController
|
||||
{
|
||||
use ChecksCsrf;
|
||||
|
||||
#[Route('', name: 'save', methods: ['POST'])]
|
||||
public function save(Request $request, #[CurrentUser] User $user, ProfileUpdater $updater, ProfileSectionViewData $section): Response
|
||||
{
|
||||
@@ -77,4 +84,87 @@ final class ProfileController extends AbstractController
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Cache-Control' => 'private, max-age=604800']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a handwritten signature, drawn in Settings or uploaded.
|
||||
*
|
||||
* A picture of a name, kept so it does not have to be drawn again on every
|
||||
* document. It is not a credential and there is nothing to verify against
|
||||
* it — App\Domain\Helper\SignatureStorage says so at length, and the
|
||||
* interface is careful about the same distinction.
|
||||
*
|
||||
* PNG only, established from the BYTES. A signature is stamped onto
|
||||
* somebody else's document and has to have a transparent background;
|
||||
* accepting a JPEG would be accepting a white card over their text rather
|
||||
* than supporting a second format.
|
||||
*/
|
||||
#[Route('/signature', name: 'signature_save', methods: ['POST'])]
|
||||
public function saveSignature(
|
||||
Request $request,
|
||||
#[CurrentUser] User $user,
|
||||
SignatureStorage $signatures,
|
||||
EntityManagerInterface $entityManager,
|
||||
): Response {
|
||||
$this->assertCsrf($request, 'profile_signature');
|
||||
|
||||
$file = $request->files->get('signature');
|
||||
|
||||
if (false === $file instanceof UploadedFile || false === $file->isValid()) {
|
||||
throw new BadRequestHttpException('No signature.');
|
||||
}
|
||||
|
||||
if (false === $signatures->isAcceptable((string) $file->getMimeType(), (int) $file->getSize())) {
|
||||
throw new BadRequestHttpException('Not an acceptable signature image.');
|
||||
}
|
||||
|
||||
$user->signature = $signatures->store(
|
||||
(string) $user->id,
|
||||
(string) file_get_contents($file->getPathname()),
|
||||
);
|
||||
|
||||
$entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'profile']);
|
||||
}
|
||||
|
||||
/** Forget it, and delete the file rather than only the pointer. */
|
||||
#[Route('/signature/delete', name: 'signature_delete', methods: ['POST'])]
|
||||
public function deleteSignature(
|
||||
Request $request,
|
||||
#[CurrentUser] User $user,
|
||||
SignatureStorage $signatures,
|
||||
EntityManagerInterface $entityManager,
|
||||
): Response {
|
||||
$this->assertCsrf($request, 'profile_signature');
|
||||
|
||||
$signatures->deleteAllFor((string) $user->id);
|
||||
$user->signature = null;
|
||||
|
||||
$entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'profile']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the saved signature, to the user it belongs to and nobody else.
|
||||
*
|
||||
* Same shape as avatar() above and for the same reason: the filename is
|
||||
* random, but a route that hands back any path under the directory is not
|
||||
* something to leave lying around.
|
||||
*/
|
||||
#[Route('/signature/{filename}', name: 'signature', requirements: ['filename' => '[0-9a-f]+\.png'], methods: ['GET'])]
|
||||
public function signature(string $filename, #[CurrentUser] User $user, SignatureStorage $signatures): Response
|
||||
{
|
||||
if ($user->signature !== $filename) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$path = $signatures->pathFor((string) $user->id, $filename);
|
||||
|
||||
if (false === is_file($path)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Cache-Control' => 'private, max-age=604800']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Helper;
|
||||
|
||||
/**
|
||||
* Where a user's saved signature lives on disk.
|
||||
*
|
||||
* Modelled on AvatarStorage, and deliberately so: one file per user under
|
||||
* var/uploads, replaced in place rather than versioned, filename random so a
|
||||
* changed image is not served from cache. Same disk-not-database reasoning too
|
||||
* — a PNG has no business in a row that is read on every request.
|
||||
*
|
||||
* WHAT THIS IS NOT
|
||||
*
|
||||
* Not a credential, and not key material. It is a picture of a name, and the
|
||||
* signatures it stamps are visual ones: what a printed-signed-scanned copy is
|
||||
* worth, no more. Nothing here should ever be treated as proving who signed
|
||||
* something, and it is encrypted at rest by nothing, which is the honest
|
||||
* treatment for an image of a squiggle.
|
||||
*
|
||||
* It is still the user's own and is served only to them — see
|
||||
* ProfileController::avatar(), which this follows.
|
||||
*
|
||||
* WHY PNG ONLY, AND WHY THE CEILING IS SMALL
|
||||
*
|
||||
* The image is stamped onto somebody else's document, so it has to have a
|
||||
* transparent background — anything else lands as a white card over the text
|
||||
* beneath it. PNG is the only format the signing pad produces and the only one
|
||||
* that carries alpha reliably, so accepting JPEG would be accepting a bad
|
||||
* result rather than a broader one.
|
||||
*
|
||||
* A megabyte is enormous for a trimmed scribble: the pad's own output is a few
|
||||
* kilobytes. The ceiling is there for an uploaded scan, which is the case where
|
||||
* somebody hands over a phone photo.
|
||||
*/
|
||||
readonly class SignatureStorage
|
||||
{
|
||||
public const array ALLOWED_MIME = ['image/png'];
|
||||
|
||||
public const int MAX_BYTES = 1024 * 1024;
|
||||
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private string $storageDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAcceptable(string $mime, int $bytes): bool
|
||||
{
|
||||
return $bytes <= self::MAX_BYTES && in_array($mime, self::ALLOWED_MIME, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string the stored filename, to put on the user
|
||||
*/
|
||||
public function store(string $userId, string $contents): string
|
||||
{
|
||||
$directory = $this->directory($userId);
|
||||
|
||||
if (false === is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
$this->deleteAllFor($userId);
|
||||
|
||||
// Random rather than fixed: the filename is in the URL, and a stable
|
||||
// one would be served from cache after a change.
|
||||
$filename = bin2hex(random_bytes(8)) . '.png';
|
||||
|
||||
file_put_contents($directory . '/' . $filename, $contents);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function pathFor(string $userId, string $filename): string
|
||||
{
|
||||
return $this->directory($userId) . '/' . $filename;
|
||||
}
|
||||
|
||||
public function deleteAllFor(string $userId): void
|
||||
{
|
||||
foreach (glob($this->directory($userId) . '/*') ?: [] as $existing) {
|
||||
if (true === is_file($existing)) {
|
||||
unlink($existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function directory(string $userId): string
|
||||
{
|
||||
return $this->projectDir . '/' . $this->storageDir . '/uploads/signatures/' . $userId;
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,21 @@ class User extends UserEntityModel implements UserInterface, PasswordAuthenticat
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
public ?string $avatar = null;
|
||||
|
||||
/**
|
||||
* The filename of a saved handwritten signature, for stamping onto PDFs.
|
||||
*
|
||||
* A filename rather than the image, for the same reason the avatar is one:
|
||||
* a PNG has no business in a row that is read on every request. Nullable
|
||||
* because most people will never save one, and clearing it is a normal
|
||||
* thing to do.
|
||||
*
|
||||
* It is a picture of a name and nothing more — no key material, nothing to
|
||||
* verify against. See App\Domain\Helper\SignatureStorage, which is careful
|
||||
* about the difference.
|
||||
*/
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
public ?string $signature = null;
|
||||
|
||||
/**
|
||||
* The shared TOTP secret, base32, encrypted at rest by the same libsodium
|
||||
* key as mailbox passwords — anyone who can read it can mint valid codes
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Demo;
|
||||
|
||||
use App\Domain\Helper\AvatarStorage;
|
||||
use App\Domain\Helper\SignatureStorage;
|
||||
use App\Entity\User\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
@@ -39,6 +41,8 @@ final readonly class DemoUserEraser
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private DemoMode $demoMode,
|
||||
private AvatarStorage $avatars,
|
||||
private SignatureStorage $signatures,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -68,6 +72,21 @@ final readonly class DemoUserEraser
|
||||
}
|
||||
}
|
||||
|
||||
// Files, which no row cascade reaches. Rows are discovered from
|
||||
// Doctrine's metadata; a PNG under var/uploads is not a row and is
|
||||
// invisible to that, so each blob store owned by a user has to be named
|
||||
// here. On a public demo minting a throwaway visitor at a time, a
|
||||
// signature left behind is not untidiness — it is unbounded growth on
|
||||
// somebody's disk.
|
||||
//
|
||||
// Before the flush, and outside any transaction it might join: deleting
|
||||
// a file cannot be rolled back, so the ordering that loses least is
|
||||
// files first and rows after. A file removed for a user whose deletion
|
||||
// then failed is recoverable — they redraw it. The other way round
|
||||
// leaves an orphan nothing will ever find again.
|
||||
$this->avatars->deleteAllFor((string) $user->id);
|
||||
$this->signatures->deleteAllFor((string) $user->id);
|
||||
|
||||
$this->entityManager->remove($user);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ proves the document is unaltered.
|
||||
data-mail--pdf-sign-filename-value="{{ signedName }}"
|
||||
data-mail--pdf-sign-encrypted-text-value="{{ 'attachment.sign.protected'|trans }}"
|
||||
data-mail--pdf-sign-unreadable-text-value="{{ 'attachment.sign.unreadable'|trans }}"
|
||||
data-mail--pdf-sign-saved-url-value="{{ app.user.signature ? path('app_settings_profile_signature', { filename: app.user.signature }) : '' }}"
|
||||
data-mail--pdf-viewer-src-value="{{ path('app_mail_attachment', { id: part.id }) }}"
|
||||
data-mail--pdf-viewer-assets-value="{{ assets|json_encode }}"
|
||||
data-mail--pdf-viewer-failed-text-value="{{ 'attachment.preview.failed'|trans }}">
|
||||
@@ -65,6 +66,16 @@ proves the document is unaltered.
|
||||
<span data-mail--pdf-viewer-target="pages"
|
||||
class="hidden mr-2 text-xs text-ink-faint tabular-nums whitespace-nowrap"></span>
|
||||
|
||||
{% if app.user.signature %}
|
||||
<button type="button"
|
||||
data-action="mail--pdf-sign#useSaved"
|
||||
class="mr-1 inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium
|
||||
text-ink-soft hover:bg-hover hover:text-ink transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-stamp text-[11px]" aria-hidden="true"></i>
|
||||
{{ 'attachment.sign.use_saved'|trans }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button type="button"
|
||||
data-action="mail--pdf-sign#open"
|
||||
class="mr-1 inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium
|
||||
|
||||
@@ -40,3 +40,78 @@ find.
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{#
|
||||
The saved handwritten signature.
|
||||
|
||||
A second card rather than a field on the form above, because it is not a form
|
||||
field: the pad hands the file input a PNG it just drew, and mixing that into the
|
||||
profile form would mean a half-drawn signature riding along with a name change.
|
||||
|
||||
Deliberately NOT the "Signature" section in the sidebar — that one is the block
|
||||
of text appended to outgoing mail, and the two have nothing to do with each
|
||||
other. This lives beside the avatar, which is what it is: a picture of you.
|
||||
#}
|
||||
{% embed 'settings/_card.html.twig' with {
|
||||
heading: 'settings.handwriting.heading'|trans,
|
||||
subheading: 'settings.handwriting.subheading'|trans,
|
||||
padded: true,
|
||||
} %}
|
||||
{% block body %}
|
||||
<div class="max-w-md">
|
||||
{% if app.user.signature %}
|
||||
<div class="mb-4">
|
||||
<img src="{{ path('app_settings_profile_signature', { filename: app.user.signature }) }}"
|
||||
alt="{{ 'settings.handwriting.saved'|trans }}"
|
||||
class="h-16 max-w-full object-contain rounded-lg border border-line bg-white p-2">
|
||||
|
||||
<form action="{{ path('app_settings_profile_signature_delete') }}" method="post" class="mt-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('profile_signature') }}">
|
||||
<button type="submit"
|
||||
class="text-xs text-ink-muted hover:text-danger transition-colors cursor-pointer">
|
||||
{{ 'settings.handwriting.remove'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ path('app_settings_profile_signature_save') }}"
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
data-controller="settings--signature-pad">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('profile_signature') }}">
|
||||
<input type="file" name="signature" data-settings--signature-pad-target="file" hidden>
|
||||
|
||||
{# touch-none is what stops a finger stroke scrolling the page
|
||||
instead of drawing on the pad. #}
|
||||
<canvas data-settings--signature-pad-target="ink"
|
||||
data-action="pointerdown->settings--signature-pad#startStroke
|
||||
pointermove->settings--signature-pad#stroke
|
||||
pointerup->settings--signature-pad#endStroke
|
||||
pointerleave->settings--signature-pad#endStroke
|
||||
pointercancel->settings--signature-pad#endStroke"
|
||||
class="block h-28 w-full touch-none rounded-lg border border-dashed border-line
|
||||
bg-white cursor-crosshair"></canvas>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button type="submit"
|
||||
data-settings--signature-pad-target="save"
|
||||
disabled
|
||||
class="inline-flex items-center justify-center h-9 px-4 rounded-lg text-sm font-medium
|
||||
text-accent-ink bg-accent hover:bg-accent-strong
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
transition-colors duration-100 cursor-pointer">
|
||||
{{ 'settings.handwriting.save'|trans }}
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
data-action="settings--signature-pad#clear"
|
||||
class="h-9 px-3 rounded-lg text-sm text-ink-muted hover:bg-hover hover:text-ink
|
||||
transition-colors cursor-pointer">
|
||||
{{ 'settings.handwriting.clear'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
@@ -16,6 +16,7 @@ use App\Service\Demo\DemoScenario;
|
||||
use App\Service\Demo\DemoMode;
|
||||
use App\Service\Demo\DemoProvisioner;
|
||||
use App\Service\Demo\DemoScenarios;
|
||||
use App\Domain\Helper\SignatureStorage;
|
||||
use App\Service\Demo\DemoUserEraser;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
@@ -588,6 +589,37 @@ final class DemoFlowTest extends WebTestCase
|
||||
static::getContainer()->get(DemoUserEraser::class)->erase($realUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erasing a demo visitor takes their files with them.
|
||||
*
|
||||
* The rows are found through Doctrine's metadata, and a PNG under
|
||||
* var/uploads is not a row — so a blob store owned by a user is invisible
|
||||
* to that walk and has to be named explicitly. On a public demo minting a
|
||||
* throwaway visitor at a time, one file left behind per visitor is
|
||||
* unbounded growth on somebody's disk rather than untidiness.
|
||||
*/
|
||||
public function testErasingAVisitorDeletesTheirSavedSignature(): void
|
||||
{
|
||||
$client = $this->demoClient();
|
||||
|
||||
$client->request('GET', '/demo');
|
||||
$client->followRedirect();
|
||||
|
||||
$visitor = $this->visitor();
|
||||
|
||||
self::assertNotNull($visitor, 'the demo should have provisioned a visitor');
|
||||
|
||||
$signatures = static::getContainer()->get(SignatureStorage::class);
|
||||
$filename = $signatures->store((string) $visitor->id, 'not really a png, and it does not have to be');
|
||||
$path = $signatures->pathFor((string) $visitor->id, $filename);
|
||||
|
||||
self::assertFileExists($path);
|
||||
|
||||
static::getContainer()->get(DemoUserEraser::class)->erase($visitor);
|
||||
|
||||
self::assertFileDoesNotExist($path, 'the signature outlived the user it belonged to');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the lockdown
|
||||
|
||||
public function testAttachingARealMailboxIsRefusedOnADemo(): void
|
||||
|
||||
@@ -161,6 +161,15 @@ test.describe("admin integrations", () => {
|
||||
// suite, on the run where the machine was busiest.
|
||||
const drive = providerRow(page, "Google Drive");
|
||||
await drive.locator("summary").click();
|
||||
|
||||
// The wait, not a redundant assertion. Opening a row loads its body
|
||||
// into the frame, and the button does not exist until that lands —
|
||||
// clicking straight through auto-waits for the BUTTON but not for the
|
||||
// frame, so the click can be aimed at a row the response then re-renders
|
||||
// closed. Waiting for it to be visible is waiting for the frame to
|
||||
// settle, and removing this line is what put the flake back.
|
||||
await expect(drive.getByRole("button", { name: /Reuse Gmail sign-in/ })).toBeVisible();
|
||||
|
||||
await drive.getByRole("button", { name: /Reuse Gmail sign-in/ }).click();
|
||||
|
||||
// Wait for the copy to land before reopening: the response replaces the
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { test, expect, type Page } from "./support/test";
|
||||
import { seed } from "./support/config";
|
||||
|
||||
/**
|
||||
* A signature drawn once in Settings, and used on a PDF without drawing again.
|
||||
*
|
||||
* ⚠ JavaScript and CSS both. Run `php bin/console tailwind:build` and then
|
||||
* `asset-map:compile` after changing anything under assets/ — a stylesheet
|
||||
* change skipped by the first one is served as a valid file your rule is simply
|
||||
* not in, which looks like a specificity problem and is not.
|
||||
*
|
||||
* The property worth pinning is that a saved signature and a freshly drawn one
|
||||
* go through the SAME placement path. A stamp is an image from the moment it is
|
||||
* captured rather than a set of strokes, and that is what makes "use the saved
|
||||
* one" six lines instead of a second implementation to keep in step.
|
||||
*
|
||||
* It is a picture of a name. Nothing here asserts anything about authenticity,
|
||||
* because there is nothing to assert.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
seed("seed-mail", "seed-attachment");
|
||||
|
||||
// A saved signature outlives the seeds — it is a file on disk and a column
|
||||
// on the user, and no app:test:* command touches either. Left alone, every
|
||||
// test here would find the one the previous RUN saved, and "it saved
|
||||
// correctly" would pass without anything being saved at all. That is not
|
||||
// hypothetical: it is what these tests did while the save endpoint was
|
||||
// answering 500.
|
||||
await page.goto("/settings?section=profile");
|
||||
|
||||
const remove = page.getByRole("button", { name: "Remove saved signature" });
|
||||
|
||||
if (0 < (await remove.count())) {
|
||||
await remove.click();
|
||||
await expect(page.getByAltText("Your saved signature")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
const PAD = '[data-settings--signature-pad-target="ink"]';
|
||||
const STAMP = '[data-mail--pdf-sign-target="stamp"]';
|
||||
const PAGES = '[data-mail--pdf-viewer-target="stack"] canvas';
|
||||
|
||||
/** Draws on whichever pad is on screen, the way a pointer actually would. */
|
||||
async function scribbleOn(page: Page, selector: string): Promise<void> {
|
||||
const pad = page.locator(selector);
|
||||
await expect(pad).toBeVisible();
|
||||
|
||||
// Into view BEFORE the box is measured. page.mouse works in viewport
|
||||
// coordinates, and boundingBox() happily reports a box below the fold — so
|
||||
// without this the strokes land somewhere else entirely and the pad stays
|
||||
// blank, which reads as "drawing is broken" rather than "the test aimed
|
||||
// outside the window".
|
||||
await pad.scrollIntoViewIfNeeded();
|
||||
|
||||
const box = (await pad.boundingBox())!;
|
||||
const y = box.y + box.height / 2;
|
||||
|
||||
await page.mouse.move(box.x + 20, y);
|
||||
await page.mouse.down();
|
||||
|
||||
for (let step = 1; step <= 8; step += 1) {
|
||||
await page.mouse.move(box.x + 20 + step * 12, y + (step % 2 === 0 ? -14 : 14));
|
||||
}
|
||||
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** Saves one, so the tests below have something to find. */
|
||||
async function saveSignature(page: Page): Promise<void> {
|
||||
await page.goto("/settings?section=profile");
|
||||
await scribbleOn(page, PAD);
|
||||
|
||||
await page.getByRole("button", { name: "Save signature" }).click();
|
||||
await expect(page.getByAltText("Your saved signature")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("a saved signature", () => {
|
||||
/**
|
||||
* Saving is an ordinary form post, so it survives a reload — which is the
|
||||
* difference between a saved signature and one held in a tab.
|
||||
*/
|
||||
test("is drawn in Settings and stays there", async ({ page }) => {
|
||||
await saveSignature(page);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByAltText("Your saved signature")).toBeVisible();
|
||||
});
|
||||
|
||||
/** An untouched pad has nothing to save, and must not pretend otherwise. */
|
||||
test("cannot be saved before anything is drawn", async ({ page }) => {
|
||||
await page.goto("/settings?section=profile");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Save signature" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("is offered in the reader and places without drawing again", async ({ page }) => {
|
||||
await saveSignature(page);
|
||||
|
||||
await page.goto("/mail/inbox");
|
||||
await page.locator("#message-list li").filter({ hasText: "E2E Attachment" }).first().click();
|
||||
await page.getByRole("button", { name: /e2e-document\.pdf/ }).click();
|
||||
await expect(page.locator(PAGES).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.getByRole("button", { name: "Use saved signature" }).click();
|
||||
|
||||
const stamp = page.locator(STAMP);
|
||||
await expect(stamp).toBeVisible();
|
||||
|
||||
// Carrying the saved image, not an empty outline — and the pad never
|
||||
// opened, which is the whole point of having saved one.
|
||||
await expect(stamp).toHaveAttribute("style", /background-image: url/);
|
||||
await expect(page.locator('[data-mail--pdf-sign-target="ink"]')).toBeHidden();
|
||||
});
|
||||
|
||||
/**
|
||||
* Removing it removes the offer too. A button that places nothing is worse
|
||||
* than no button, and this is the assertion that fails if the toolbar ever
|
||||
* starts rendering it unconditionally.
|
||||
*/
|
||||
test("can be removed, and stops being offered", async ({ page }) => {
|
||||
await saveSignature(page);
|
||||
|
||||
await page.getByRole("button", { name: "Remove saved signature" }).click();
|
||||
await expect(page.getByAltText("Your saved signature")).toHaveCount(0);
|
||||
|
||||
await page.goto("/mail/inbox");
|
||||
await page.locator("#message-list li").filter({ hasText: "E2E Attachment" }).first().click();
|
||||
await page.getByRole("button", { name: /e2e-document\.pdf/ }).click();
|
||||
await expect(page.locator(PAGES).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await expect(page.getByRole("button", { name: "Use saved signature" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -675,6 +675,7 @@ attachment:
|
||||
reply: Mit unterschriebener Kopie antworten
|
||||
protected: Dieses PDF ist geschützt und kann hier nicht unterschrieben werden. Lesen geht weiterhin.
|
||||
unreadable: Dieses PDF lässt sich hier nicht bearbeiten. Lesen geht weiterhin.
|
||||
use_saved: Gespeicherte Unterschrift verwenden
|
||||
preview:
|
||||
download: Herunterladen
|
||||
failed: Dieses PDF konnte nicht geöffnet werden. Du kannst es trotzdem herunterladen.
|
||||
@@ -2339,6 +2340,13 @@ settings:
|
||||
then: Dann
|
||||
when: Wenn
|
||||
yes: Ja
|
||||
handwriting:
|
||||
heading: Handschriftliche Unterschrift
|
||||
subheading: Einmal zeichnen und danach auf jedes PDF setzen, ohne sie neu zu zeichnen. Das ist ein Bild deines Namens, kein digitales Zertifikat.
|
||||
saved: Deine gespeicherte Unterschrift
|
||||
save: Unterschrift speichern
|
||||
clear: Löschen
|
||||
remove: Gespeicherte Unterschrift entfernen
|
||||
health:
|
||||
awaiting:
|
||||
calendar_resync: >-
|
||||
|
||||
@@ -670,6 +670,7 @@ attachment:
|
||||
reply: Reply with signed copy
|
||||
protected: This PDF is protected, so it cannot be signed here. Reading it still works.
|
||||
unreadable: This PDF cannot be edited here. Reading it still works.
|
||||
use_saved: Use saved signature
|
||||
preview:
|
||||
download: Download
|
||||
failed: This PDF could not be opened. You can still download it.
|
||||
@@ -2331,6 +2332,16 @@ settings:
|
||||
then: Then
|
||||
when: When
|
||||
yes: Yes
|
||||
handwriting:
|
||||
# The HANDWRITTEN signature — a picture stamped onto a PDF. Not the
|
||||
# `signature` section, which is the text appended to outgoing mail. The
|
||||
# two are unrelated and the wording has to keep them apart.
|
||||
heading: Handwritten signature
|
||||
subheading: Draw it once, and place it on a PDF without drawing it again. It is a picture of your name, not a digital certificate.
|
||||
saved: Your saved signature
|
||||
save: Save signature
|
||||
clear: Clear
|
||||
remove: Remove saved signature
|
||||
health:
|
||||
awaiting:
|
||||
calendar_resync: >-
|
||||
|
||||
@@ -412,6 +412,7 @@ attachment:
|
||||
reply: Answer with the marked copy
|
||||
protected: This scroll be sealed, so ye cannot mark it here. Readin' still works.
|
||||
unreadable: This scroll cannot be marked here. Readin' still works.
|
||||
use_saved: Use yer stowed mark
|
||||
preview:
|
||||
download: Haul aboard
|
||||
failed: This scroll would not unroll. Ye can still haul it aboard.
|
||||
@@ -1984,6 +1985,13 @@ settings:
|
||||
then: Then
|
||||
when: When
|
||||
yes: Aye
|
||||
handwriting:
|
||||
heading: Yer own mark
|
||||
subheading: Scrawl it once and slap it on any scroll thereafter. 'Tis a picture of yer name, not a seal of office.
|
||||
saved: Yer stowed mark
|
||||
save: Stow the mark
|
||||
clear: Scrub it out
|
||||
remove: Throw the stowed mark overboard
|
||||
health:
|
||||
awaiting:
|
||||
calendar_resync: >-
|
||||
|
||||
Reference in new issue
Block a user