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>
72 lines
2 KiB
PHP
72 lines
2 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Models\Concerns\OwnedByUser;
|
|
use App\Models\Scopes\OwnedByUserScope;
|
|
use Database\Factories\ShareFactory;
|
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* @property int $id
|
|
* @property int $user_id
|
|
* @property string $token
|
|
* @property string|null $recipient_email
|
|
* @property string|null $password
|
|
* @property Carbon $expires_at
|
|
* @property Carbon|null $revoked_at
|
|
* @property int $access_count
|
|
* @property Carbon|null $last_accessed_at
|
|
* @property Carbon|null $created_at
|
|
* @property Carbon|null $updated_at
|
|
*/
|
|
#[Fillable(['token', 'recipient_email', 'password', 'expires_at', 'revoked_at'])]
|
|
#[ScopedBy(OwnedByUserScope::class)]
|
|
class Share extends Model
|
|
{
|
|
/** @use HasFactory<ShareFactory> */
|
|
use HasFactory, OwnedByUser;
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
// Encrypted (not hashed) so the owner can re-read it and the ZIP
|
|
// can be AES-encrypted with the same password on download.
|
|
'password' => 'encrypted',
|
|
'expires_at' => 'datetime',
|
|
'revoked_at' => 'datetime',
|
|
'last_accessed_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return BelongsToMany<Certificate, $this>
|
|
*/
|
|
public function certificates(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Certificate::class);
|
|
}
|
|
|
|
public function isActive(): bool
|
|
{
|
|
return $this->revoked_at === null && $this->expires_at->isFuture();
|
|
}
|
|
|
|
public function url(): string
|
|
{
|
|
return route('shared.show', $this->token);
|
|
}
|
|
|
|
public function recordAccess(): void
|
|
{
|
|
$this->increment('access_count', extra: ['last_accessed_at' => now()]);
|
|
}
|
|
}
|