Certified/app/Models/Share.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

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()]);
}
}