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>
81 lines
2.3 KiB
PHP
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;
|
|
}
|
|
}
|