Certified/app/Services/CertificateZipper.php
Joël van de Wouw ad2a78a989 Replace email-only sharing with secure share pages
Sharing certificates now creates a Share: a public page behind an
unguessable token where the recipient views and downloads the selected
certificates individually or as a ZIP, instead of receiving links or
attachments by email.

- Share model + certificate_share pivot; expiration (1/7/30 days),
  optional password (encrypted so the owner can re-view it), revocation,
  and open tracking
- Public routes under shared/{token}: password unlock gate (throttled,
  session-scoped), per-certificate download, on-the-fly ZIP that is
  AES-256 encrypted when the share has a password, friendly 410 page
  for expired/revoked links
- Share modal on the certificates index now creates the page and
  reveals a copyable link + password; optional transactional email
  (ShareCreatedMail) still sends the link to a recipient
- New Shares page in the sidebar to copy links, look up passwords,
  revoke, and delete shares
- Old signed-URL controller and both attachment/link mailables removed;
  Dutch translations updated; Pest coverage for the full lifecycle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:38:43 +02:00

81 lines
2.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\Certificate;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;
class CertificateZipper
{
/**
* Build a ZIP archive containing the given certificates' files. When a
* password is given the entries are AES-256 encrypted with it. Returns
* the absolute path to the created archive; the caller is responsible
* for deleting it once sent.
*
* @param Collection<int, Certificate> $certificates
*/
public function create(Collection $certificates, ?string $password = null): string
{
$archivePath = tempnam(sys_get_temp_dir(), 'certs_').'.zip';
$zip = new ZipArchive;
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Unable to create ZIP archive.');
}
if ($password !== null) {
$zip->setPassword($password);
}
$usedNames = [];
foreach ($certificates as $certificate) {
if (blank($certificate->file_path) || ! Storage::disk('local')->exists($certificate->file_path)) {
continue;
}
$entryName = $this->uniqueEntryName($certificate, $usedNames);
$usedNames[] = $entryName;
$zip->addFromString($entryName, Storage::disk('local')->get($certificate->file_path));
if ($password !== null) {
$zip->setEncryptionName($entryName, ZipArchive::EM_AES_256);
}
}
if ($zip->count() === 0) {
$zip->close();
@unlink($archivePath);
throw new RuntimeException('No files were available to add to the archive.');
}
$zip->close();
return $archivePath;
}
/**
* @param array<int, string> $usedNames
*/
private function uniqueEntryName(Certificate $certificate, array $usedNames): string
{
$extension = pathinfo($certificate->file_path, PATHINFO_EXTENSION);
$base = Str::slug($certificate->title) ?: 'certificate';
$name = $base.'.'.$extension;
$counter = 1;
while (in_array($name, $usedNames, true)) {
$name = $base.'-'.(++$counter).'.'.$extension;
}
return $name;
}
}