From ad2a78a9894f4ed26c1fb43b6195c07be5f9a1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20van=20de=20Wouw?= Date: Sun, 12 Jul 2026 15:38:43 +0200 Subject: [PATCH] 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 --- app/Http/Controllers/SharePageController.php | 97 +++++++++ .../SharedCertificateController.php | 25 --- ...cateLinksMail.php => ShareCreatedMail.php} | 15 +- app/Mail/SharedCertificatesMail.php | 50 ----- app/Models/Share.php | 72 +++++++ app/Services/CertificateZipper.php | 19 +- database/factories/ShareFactory.php | 50 +++++ .../2026_07_12_000001_create_shares_table.php | 40 ++++ lang/nl.json | 96 ++++++--- resources/views/layouts/app/sidebar.blade.php | 17 +- resources/views/mail/share-created.blade.php | 22 ++ .../mail/shared-certificate-links.blade.php | 18 -- .../views/mail/shared-certificates.blade.php | 12 -- .../pages/certificates/⚡index.blade.php | 159 +++++++------- .../views/pages/shares/⚡index.blade.php | 194 ++++++++++++++++++ resources/views/shared/expired.blade.php | 13 ++ resources/views/shared/layout.blade.php | 21 ++ resources/views/shared/show.blade.php | 63 ++++++ resources/views/shared/unlock.blade.php | 33 +++ routes/web.php | 16 +- .../Certificates/CertificateSharingTest.php | 121 ++++++----- tests/Feature/Shares/ShareManagementTest.php | 64 ++++++ tests/Feature/Shares/SharePageTest.php | 139 +++++++++++++ 23 files changed, 1070 insertions(+), 286 deletions(-) create mode 100644 app/Http/Controllers/SharePageController.php delete mode 100644 app/Http/Controllers/SharedCertificateController.php rename app/Mail/{SharedCertificateLinksMail.php => ShareCreatedMail.php} (68%) delete mode 100644 app/Mail/SharedCertificatesMail.php create mode 100644 app/Models/Share.php create mode 100644 database/factories/ShareFactory.php create mode 100644 database/migrations/2026_07_12_000001_create_shares_table.php create mode 100644 resources/views/mail/share-created.blade.php delete mode 100644 resources/views/mail/shared-certificate-links.blade.php delete mode 100644 resources/views/mail/shared-certificates.blade.php create mode 100644 resources/views/pages/shares/⚡index.blade.php create mode 100644 resources/views/shared/expired.blade.php create mode 100644 resources/views/shared/layout.blade.php create mode 100644 resources/views/shared/show.blade.php create mode 100644 resources/views/shared/unlock.blade.php create mode 100644 tests/Feature/Shares/ShareManagementTest.php create mode 100644 tests/Feature/Shares/SharePageTest.php diff --git a/app/Http/Controllers/SharePageController.php b/app/Http/Controllers/SharePageController.php new file mode 100644 index 0000000..0b427a0 --- /dev/null +++ b/app/Http/Controllers/SharePageController.php @@ -0,0 +1,97 @@ +isActive()) { + return response()->view('shared.expired', status: 410); + } + + if (! $this->isUnlocked($share)) { + return view('shared.unlock', ['share' => $share]); + } + + $share->recordAccess(); + + return view('shared.show', [ + 'share' => $share, + 'certificates' => $share->certificates()->whereNotNull('file_path')->orderBy('title')->get(), + ]); + } + + public function unlock(Request $request, Share $share): RedirectResponse + { + abort_unless($share->isActive(), 410); + + $validated = $request->validate(['password' => ['required', 'string']]); + + if ($share->password === null || ! hash_equals($share->password, $validated['password'])) { + return back()->withErrors(['password' => __('The password is incorrect.')]); + } + + $request->session()->put($this->sessionKey($share), true); + + return redirect()->route('shared.show', $share->token); + } + + public function download(Share $share, Certificate $certificate): StreamedResponse + { + $this->authorizeDownload($share); + + abort_unless(filled($certificate->file_path) && Storage::disk('local')->exists($certificate->file_path), 404); + + return Storage::disk('local')->download( + $certificate->file_path, + $certificate->title.'.'.pathinfo($certificate->file_path, PATHINFO_EXTENSION), + ); + } + + public function zip(Share $share): BinaryFileResponse + { + $this->authorizeDownload($share); + + $certificates = $share->certificates()->whereNotNull('file_path')->get(); + + abort_if($certificates->isEmpty(), 404); + + $zipPath = app(CertificateZipper::class)->create($certificates, $share->password); + + return response()->download($zipPath, 'certificates.zip')->deleteFileAfterSend(); + } + + private function authorizeDownload(Share $share): void + { + abort_unless($share->isActive(), 410); + abort_unless($this->isUnlocked($share), 403); + } + + private function isUnlocked(Share $share): bool + { + return $share->password === null || session()->get($this->sessionKey($share)) === true; + } + + private function sessionKey(Share $share): string + { + return 'share-unlocked:'.$share->id; + } +} diff --git a/app/Http/Controllers/SharedCertificateController.php b/app/Http/Controllers/SharedCertificateController.php deleted file mode 100644 index 228970a..0000000 --- a/app/Http/Controllers/SharedCertificateController.php +++ /dev/null @@ -1,25 +0,0 @@ -file_path) && Storage::disk('local')->exists($certificate->file_path), 404); - - return Storage::disk('local')->download( - $certificate->file_path, - $certificate->title.'.'.pathinfo($certificate->file_path, PATHINFO_EXTENSION), - ); - } -} diff --git a/app/Mail/SharedCertificateLinksMail.php b/app/Mail/ShareCreatedMail.php similarity index 68% rename from app/Mail/SharedCertificateLinksMail.php rename to app/Mail/ShareCreatedMail.php index 2ed627b..1149a7e 100644 --- a/app/Mail/SharedCertificateLinksMail.php +++ b/app/Mail/ShareCreatedMail.php @@ -8,17 +8,16 @@ use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class SharedCertificateLinksMail extends Mailable +class ShareCreatedMail extends Mailable { use Queueable, SerializesModels; - /** - * @param array $links - */ public function __construct( public string $senderName, - public array $links, + public string $shareUrl, public string $expiresAt, + public int $certificateCount, + public bool $hasPassword, ) {} public function envelope(): Envelope @@ -31,11 +30,13 @@ class SharedCertificateLinksMail extends Mailable public function content(): Content { return new Content( - markdown: 'mail.shared-certificate-links', + markdown: 'mail.share-created', with: [ 'senderName' => $this->senderName, - 'links' => $this->links, + 'shareUrl' => $this->shareUrl, 'expiresAt' => $this->expiresAt, + 'certificateCount' => $this->certificateCount, + 'hasPassword' => $this->hasPassword, ], ); } diff --git a/app/Mail/SharedCertificatesMail.php b/app/Mail/SharedCertificatesMail.php deleted file mode 100644 index 87c6a69..0000000 --- a/app/Mail/SharedCertificatesMail.php +++ /dev/null @@ -1,50 +0,0 @@ - $this->senderName]), - ); - } - - public function content(): Content - { - return new Content( - markdown: 'mail.shared-certificates', - with: ['senderName' => $this->senderName], - ); - } - - /** - * @return array - */ - public function attachments(): array - { - return [ - Attachment::fromPath($this->zipPath) - ->as('certificates.zip') - ->withMime('application/zip'), - ]; - } -} diff --git a/app/Models/Share.php b/app/Models/Share.php new file mode 100644 index 0000000..2c8d48e --- /dev/null +++ b/app/Models/Share.php @@ -0,0 +1,72 @@ + */ + use HasFactory, OwnedByUser; + + /** + * @return array + */ + 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 + */ + 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()]); + } +} diff --git a/app/Services/CertificateZipper.php b/app/Services/CertificateZipper.php index f35ab8c..8ca2995 100644 --- a/app/Services/CertificateZipper.php +++ b/app/Services/CertificateZipper.php @@ -12,13 +12,14 @@ use ZipArchive; class CertificateZipper { /** - * Build a password-protected (AES-256) ZIP archive containing the given - * certificates' files. Returns the absolute path to the created archive; - * the caller is responsible for deleting it once sent. + * 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 $certificates */ - public function create(Collection $certificates, string $password): string + public function create(Collection $certificates, ?string $password = null): string { $archivePath = tempnam(sys_get_temp_dir(), 'certs_').'.zip'; @@ -28,7 +29,10 @@ class CertificateZipper throw new RuntimeException('Unable to create ZIP archive.'); } - $zip->setPassword($password); + if ($password !== null) { + $zip->setPassword($password); + } + $usedNames = []; foreach ($certificates as $certificate) { @@ -40,7 +44,10 @@ class CertificateZipper $usedNames[] = $entryName; $zip->addFromString($entryName, Storage::disk('local')->get($certificate->file_path)); - $zip->setEncryptionName($entryName, ZipArchive::EM_AES_256); + + if ($password !== null) { + $zip->setEncryptionName($entryName, ZipArchive::EM_AES_256); + } } if ($zip->count() === 0) { diff --git a/database/factories/ShareFactory.php b/database/factories/ShareFactory.php new file mode 100644 index 0000000..7dfc124 --- /dev/null +++ b/database/factories/ShareFactory.php @@ -0,0 +1,50 @@ + + */ +class ShareFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'token' => Str::random(48), + 'recipient_email' => null, + 'password' => null, + 'expires_at' => now()->addDays(7), + 'revoked_at' => null, + ]; + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDay(), + ]); + } + + public function revoked(): static + { + return $this->state(fn (array $attributes) => [ + 'revoked_at' => now()->subHour(), + ]); + } + + public function passwordProtected(string $password = 'secret-password'): static + { + return $this->state(fn (array $attributes) => [ + 'password' => $password, + ]); + } +} diff --git a/database/migrations/2026_07_12_000001_create_shares_table.php b/database/migrations/2026_07_12_000001_create_shares_table.php new file mode 100644 index 0000000..616f339 --- /dev/null +++ b/database/migrations/2026_07_12_000001_create_shares_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('token', 64)->unique(); + $table->string('recipient_email')->nullable(); + $table->text('password')->nullable(); + $table->dateTime('expires_at'); + $table->dateTime('revoked_at')->nullable(); + $table->unsignedInteger('access_count')->default(0); + $table->dateTime('last_accessed_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'expires_at']); + }); + + Schema::create('certificate_share', function (Blueprint $table) { + $table->id(); + $table->foreignId('certificate_id')->constrained()->cascadeOnDelete(); + $table->foreignId('share_id')->constrained()->cascadeOnDelete(); + + $table->unique(['certificate_id', 'share_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('certificate_share'); + Schema::dropIfExists('shares'); + } +}; diff --git a/lang/nl.json b/lang/nl.json index a608c27..3742cf3 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -1,13 +1,18 @@ { "\":name\" will be deleted. Certificates in this category are kept and become uncategorized.": "\":name\" wordt verwijderd. Certificaten in deze categorie blijven behouden en worden ongecategoriseerd.", "\":title\" and its stored file will be permanently deleted.": "\":title\" en het bijbehorende bestand worden permanent verwijderd.", + "1 day": "1 dag", "2FA recovery codes": "2FA-herstelcodes", - ":name has shared certificates with you as a password-protected ZIP archive attached to this email.": ":name heeft certificaten met je gedeeld als een met wachtwoord beveiligd ZIP-bestand, bijgevoegd bij deze e-mail.", - ":name has shared the following certificates with you.": ":name heeft de volgende certificaten met je gedeeld.", + "30 days": "30 dagen", + "7 days": "7 dagen", + ":count certificate selected|:count certificates selected": ":count certificaat geselecteerd|:count certificaten geselecteerd", + ":count time|:count times": ":count keer|:count keer", + ":name has shared :count certificate with you.|:name has shared :count certificates with you.": ":name heeft :count certificaat met je gedeeld.|:name heeft :count certificaten met je gedeeld.", ":name shared certificates with you": ":name heeft certificaten met je gedeeld", "A new verification link has been sent to the email address you provided during registration.": "Er is een nieuwe verificatielink verstuurd naar het e-mailadres dat je bij registratie hebt opgegeven.", "A new verification link has been sent to your email address.": "Er is een nieuwe verificatielink naar je e-mailadres verstuurd.", - "API key": "API-sleutel", + "A password is generated for you; share it with the recipient through a different channel (SMS or WhatsApp).": "Er wordt een wachtwoord voor je gegenereerd; deel dit met de ontvanger via een ander kanaal (sms of WhatsApp).", + "Active": "Actief", "Add": "Toevoegen", "Add a passkey to sign in without a password": "Voeg een passkey toe om zonder wachtwoord in te loggen", "Add certificate": "Certificaat toevoegen", @@ -18,9 +23,9 @@ "All categories": "Alle categorieën", "Already have an account?": "Heb je al een account?", "An overview of your certificates and their expiry status.": "Een overzicht van je certificaten en hun vervalstatus.", + "API key": "API-sleutel", "Appearance": "Weergave", "Appearance settings": "Weergave-instellingen", - "Archive password": "Archiefwachtwoord", "Are you sure you want to delete your account?": "Weet je zeker dat je je account wilt verwijderen?", "Are you sure you want to remove the passkey \":name\"? You will no longer be able to use it to sign in.": "Weet je zeker dat je de passkey \":name\" wilt verwijderen? Je kunt hem daarna niet meer gebruiken om in te loggen.", "Authenticating...": "Bezig met authenticeren...", @@ -41,6 +46,7 @@ "Certificate updated.": "Certificaat bijgewerkt.", "Certificates": "Certificaten", "Certificates shared with you": "Certificaten met je gedeeld", + "Choose the language used throughout the application": "Kies de taal die in de hele applicatie wordt gebruikt", "Click here to re-send the verification email.": "Klik hier om de verificatie-e-mail opnieuw te versturen.", "Close": "Sluiten", "Configure expiry reminders and document scanning": "Stel vervalherinneringen en documentscannen in", @@ -49,8 +55,10 @@ "Confirm with passkey": "Bevestig met passkey", "Confirming...": "Bezig met bevestigen...", "Continue": "Doorgaan", + "Create a secure page where the recipient can view and download the selected certificates.": "Maak een beveiligde pagina waar de ontvanger de geselecteerde certificaten kan bekijken en downloaden.", "Create account": "Account aanmaken", "Create an account": "Maak een account aan", + "Create share": "Link aanmaken", "Create your first category to organize your certificates.": "Maak je eerste categorie aan om je certificaten te organiseren.", "Current password": "Huidig wachtwoord", "Dark": "Donker", @@ -58,6 +66,7 @@ "Delete account": "Account verwijderen", "Delete category?": "Categorie verwijderen?", "Delete certificate?": "Certificaat verwijderen?", + "Delete share?": "Gedeelde link verwijderen?", "Delete your account and all of its resources": "Verwijder je account en alle bijbehorende gegevens", "Disable 2FA": "2FA uitschakelen", "Document preview": "Documentvoorbeeld", @@ -66,19 +75,26 @@ "Don't have an account?": "Nog geen account?", "Done": "Klaar", "Download": "Downloaden", + "Download all as ZIP": "Alles downloaden als ZIP", "Download current file": "Huidig bestand downloaden", + "Dutch": "Nederlands", + "e.g. VCA, Red Cross": "bijv. VCA, Rode Kruis", + "e.g., MacBook Pro, iPhone": "bijv. MacBook Pro, iPhone", "Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.": "Elke herstelcode kan één keer worden gebruikt om toegang te krijgen tot je account en wordt na gebruik verwijderd. Heb je meer nodig, klik dan hierboven op Codes opnieuw genereren.", "Edit": "Bewerken", "Edit certificate": "Certificaat bewerken", "Email": "E-mail", "Email address": "E-mailadres", "Email password reset link": "Verstuur link om wachtwoord opnieuw in te stellen", + "Email the link to a recipient (optional)": "E-mail de link naar een ontvanger (optioneel)", "Email verification": "E-mailverificatie", "Enable 2FA": "2FA inschakelen", "Enable two-factor authentication": "Tweestapsverificatie inschakelen", + "English": "Engels", "Ensure your account is using a long, random password to stay secure": "Zorg dat je account een lang, willekeurig wachtwoord gebruikt om veilig te blijven", "Enter the 6-digit code from your authenticator app.": "Voer de 6-cijferige code uit je authenticator-app in.", "Enter the authentication code provided by your authenticator application.": "Voer de authenticatiecode in die door je authenticator-app wordt gegeven.", + "Enter the password you received from the sender to view the shared certificates.": "Voer het wachtwoord in dat je van de afzender hebt ontvangen om de gedeelde certificaten te bekijken.", "Enter your API key": "Voer je API-sleutel in", "Enter your details below to create your account": "Vul hieronder je gegevens in om je account aan te maken", "Enter your email and password below to log in": "Vul hieronder je e-mailadres en wachtwoord in om in te loggen", @@ -87,28 +103,35 @@ "Expires": "Vervalt", "Expiring soon": "Vervalt binnenkort", "Expiry date": "Vervaldatum", - "For your security, the password is sent separately through another channel (for example SMS or WhatsApp). You will need it to open the archive.": "Voor je veiligheid wordt het wachtwoord apart via een ander kanaal verstuurd (bijvoorbeeld sms of WhatsApp). Je hebt het nodig om het archief te openen.", "Forgot password": "Wachtwoord vergeten", "Forgot your password?": "Wachtwoord vergeten?", "Full name": "Volledige naam", "Give this passkey a name to help you identify it later.": "Geef deze passkey een naam zodat je hem later herkent.", + "Go to certificates": "Naar certificaten", "Hello :name,": "Hallo :name,", "Here is the status of your certificates.": "Dit is de status van je certificaten.", "Hide recovery codes": "Herstelcodes verbergen", "If you did not expect this email, you can safely ignore it.": "Als je deze e-mail niet verwachtte, kun je hem veilig negeren.", "Issue date": "Uitgiftedatum", "Issuing authority": "Uitgevende instantie", + "Language": "Taal", + "Last opened :date.": "Laatst geopend op :date.", "Last used :time": "Laatst gebruikt :time", "Leave blank to keep the current key": "Laat leeg om de huidige sleutel te behouden", + "Leave empty to copy the link and send it yourself.": "Laat leeg om de link te kopiëren en zelf te versturen.", "Light": "Licht", + "Link & password": "Link & wachtwoord", + "Link expires after": "Link verloopt na", "Log in": "Inloggen", + "log in": "inloggen", "Log in to your account": "Log in op je account", "Log out": "Uitloggen", + "login using a recovery code": "inloggen met een herstelcode", + "login using an authentication code": "inloggen met een authenticatiecode", "Manage": "Beheren", "Manage your passkeys for passwordless sign-in": "Beheer je passkeys voor inloggen zonder wachtwoord", "Manage your profile and account settings": "Beheer je profiel- en accountinstellingen", "Manage your two-factor authentication settings": "Beheer je instellingen voor tweestapsverificatie", - "Method": "Methode", "Name": "Naam", "Needs attention": "Actie vereist", "New category name": "Naam nieuwe categorie", @@ -119,23 +142,27 @@ "No certificates in this category. Try another filter.": "Geen certificaten in deze categorie. Probeer een ander filter.", "No certificates yet": "Nog geen certificaten", "No passkeys yet": "Nog geen passkeys", + "No shares yet": "Nog niets gedeeld", "None (manual entry)": "Geen (handmatig invoeren)", "Notes": "Notities", "Nothing needs attention. All your certificates are up to date.": "Niets vereist actie. Al je certificaten zijn up-to-date.", "Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Zodra je account is verwijderd, worden alle bijbehorende gegevens permanent verwijderd. Voer je wachtwoord in om te bevestigen dat je je account definitief wilt verwijderen.", + "Open page": "Pagina openen", + "Opened": "Geopend", "Optional notes about this certificate": "Optionele notities over dit certificaat", "Optional. Leave empty to keep the current file.": "Optioneel. Laat leeg om het huidige bestand te behouden.", "Or confirm with password": "Of bevestig met wachtwoord", "Or continue with email": "Of ga verder met e-mail", + "or you can": "of je kunt", + "or, enter the code manually": "of voer de code handmatig in", "Or, return to": "Of ga terug naar", "Organize your certificates with personal categories.": "Organiseer je certificaten met persoonlijke categorieën.", - "PDF or image, up to 10 MB.": "PDF of afbeelding, tot 10 MB.", "Passkey name": "Naam passkey", "Passkeys": "Passkeys", "Passkeys are not supported in this browser.": "Passkeys worden niet ondersteund in deze browser.", "Password": "Wachtwoord", "Password updated.": "Wachtwoord bijgewerkt.", - "Password-protected ZIP attachment": "Met wachtwoord beveiligde ZIP-bijlage", + "PDF or image, up to 10 MB.": "PDF of afbeelding, tot 10 MB.", "Please confirm access to your account by entering one of your emergency recovery codes.": "Bevestig toegang tot je account door een van je noodherstelcodes in te voeren.", "Please enter your new password below": "Voer hieronder je nieuwe wachtwoord in", "Please renew it in good time to stay compliant.": "Verleng het op tijd om compliant te blijven.", @@ -143,8 +170,9 @@ "Profile": "Profiel", "Profile settings": "Profielinstellingen", "Profile updated.": "Profiel bijgewerkt.", + "Protect with a password": "Beveiligen met een wachtwoord", "Recently added": "Recent toegevoegd", - "Recipient email": "E-mailadres ontvanger", + "Recipient": "Ontvanger", "Recovery code": "Herstelcode", "Recovery codes": "Herstelcodes", "Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Met herstelcodes krijg je weer toegang als je je 2FA-apparaat kwijtraakt. Bewaar ze in een veilige wachtwoordmanager.", @@ -159,29 +187,50 @@ "Replace document": "Document vervangen", "Resend verification email": "Verificatie-e-mail opnieuw versturen", "Reset password": "Wachtwoord opnieuw instellen", + "Revoke": "Intrekken", + "Revoke share?": "Gedeelde link intrekken?", + "Revoked": "Ingetrokken", "Save": "Opslaan", "Save certificate": "Certificaat opslaan", "Save changes": "Wijzigingen opslaan", "Scanning document for suggestions…": "Document wordt gescand voor suggesties…", "Search": "Zoeken", - "Secure link (valid 48 hours)": "Beveiligde link (48 uur geldig)", - "Secure links sent to :email.": "Beveiligde links verzonden naar :email.", + "Secure link": "Beveiligde link", + "Secure pages you have shared with external recipients.": "Beveiligde pagina's die je met externe ontvangers hebt gedeeld.", "Security": "Beveiliging", "Security settings": "Beveiligingsinstellingen", "Select a document to preview it here.": "Selecteer een document om het hier te bekijken.", - "Send": "Versturen", - "Send the selected certificates to an external recipient.": "Verstuur de geselecteerde certificaten naar een externe ontvanger.", + "Select certificates on the Certificates page and choose Share to create a secure page.": "Selecteer certificaten op de pagina Certificaten en kies Delen om een beveiligde pagina te maken.", + "Send the link and the password through different channels.": "Verstuur de link en het wachtwoord via verschillende kanalen.", "Settings": "Instellingen", "Share": "Delen", "Share certificates": "Certificaten delen", + "Share created": "Link aangemaakt", + "Share deleted.": "Gedeelde link verwijderd.", + "Share details": "Details van gedeelde link", + "Share revoked. The link no longer works.": "Gedeelde link ingetrokken. Deze werkt niet meer.", + "Share this password with the recipient through a different channel (SMS or WhatsApp). You can look it up later on the Shares page.": "Deel dit wachtwoord met de ontvanger via een ander kanaal (sms of WhatsApp). Je kunt het later terugvinden op de pagina Gedeelde links.", + "Shared certificates": "Gedeelde certificaten", + "Shared securely via :app.": "Veilig gedeeld via :app.", + "Shares": "Gedeelde links", "Sign in with a passkey": "Inloggen met een passkey", "Sign up": "Registreren", "System": "Systeem", "Thanks": "Met vriendelijke groet", - "The ZIP was emailed. Share this password with the recipient through a different channel (SMS or WhatsApp). It will not be shown again.": "De ZIP is verstuurd per e-mail. Deel dit wachtwoord met de ontvanger via een ander kanaal (sms of WhatsApp). Het wordt niet opnieuw getoond.", + "The link has expired or was revoked by the sender. Ask them to share the certificates again if you still need them.": "De link is verlopen of door de afzender ingetrokken. Vraag de afzender om de certificaten opnieuw te delen als je ze nog nodig hebt.", + "The link stops working and the share disappears from this list. The certificates themselves are not deleted.": "De link werkt niet meer en verdwijnt uit deze lijst. De certificaten zelf worden niet verwijderd.", + "The link stops working immediately. The recipient will no longer be able to view or download the certificates.": "De link werkt direct niet meer. De ontvanger kan de certificaten niet langer bekijken of downloaden.", + "The page is protected with a password. :name will share it with you through a separate channel.": "De pagina is beveiligd met een wachtwoord. :name deelt dit met je via een apart kanaal.", + "The password is incorrect.": "Het wachtwoord is onjuist.", + "The recipient can view and download the certificates on this page:": "De ontvanger kan de certificaten bekijken en downloaden op deze pagina:", "The selected certificates have no files to share.": "De geselecteerde certificaten hebben geen bestanden om te delen.", - "These links are valid until :date, after which they will stop working.": "Deze links zijn geldig tot :date, daarna werken ze niet meer.", + "The ZIP archive is encrypted with the same password you used to open this page.": "Het ZIP-archief is versleuteld met hetzelfde wachtwoord waarmee je deze pagina hebt geopend.", + "There are no files available in this share anymore.": "Er zijn geen bestanden meer beschikbaar op deze pagina.", "This is a secure area of the application. Please confirm your password before continuing.": "Dit is een beveiligd gedeelte van de applicatie. Bevestig je wachtwoord voordat je verdergaat.", + "This page is available until :date, after which it will stop working.": "Deze pagina is beschikbaar tot :date, daarna werkt deze niet meer.", + "This share is no longer available": "Deze gedeelde pagina is niet meer beschikbaar", + "This share is not password protected.": "Deze gedeelde link is niet beveiligd met een wachtwoord.", + "This share is password protected": "Deze gedeelde pagina is beveiligd met een wachtwoord", "Title": "Titel", "To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.": "Scan de QR-code of voer de installatiesleutel in je authenticator-app in om tweestapsverificatie te voltooien.", "Too many share attempts. Please try again later.": "Te veel deelpogingen. Probeer het later opnieuw.", @@ -190,6 +239,7 @@ "Two-factor authentication enabled": "Tweestapsverificatie ingeschakeld", "Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.": "Tweestapsverificatie is nu ingeschakeld. Scan de QR-code of voer de installatiesleutel in je authenticator-app in.", "Uncategorized": "Ongecategoriseerd", + "Unlock": "Ontgrendelen", "Update password": "Wachtwoord bijwerken", "Update the appearance settings for your account": "Werk de weergave-instellingen van je account bij", "Update the details or replace the stored document.": "Werk de gegevens bij of vervang het opgeslagen document.", @@ -201,24 +251,14 @@ "Verify authentication code": "Authenticatiecode verifiëren", "View all": "Alles bekijken", "View recovery codes": "Herstelcodes bekijken", + "View shared certificates": "Bekijk gedeelde certificaten", "View your certificates": "Bekijk je certificaten", "Welcome": "Welkom", "Welcome back, :name": "Welkom terug, :name", "When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.": "Wanneer je tweestapsverificatie inschakelt, wordt tijdens het inloggen om een beveiligde pincode gevraagd. Deze pincode kun je ophalen uit een TOTP-ondersteunende app op je telefoon.", + "You can view and download them on a secure page:": "Je kunt ze bekijken en downloaden op een beveiligde pagina:", "You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.": "Tijdens het inloggen wordt om een beveiligde, willekeurige pincode gevraagd, die je kunt ophalen uit de TOTP-ondersteunende app op je telefoon.", "Your certificate \":title\" expires today.": "Je certificaat \":title\" verloopt vandaag.", "Your certificate \":title\" will expire on :date.": "Je certificaat \":title\" verloopt op :date.", - "Your email address is unverified.": "Je e-mailadres is niet geverifieerd.", - "e.g. VCA, Red Cross": "bijv. VCA, Rode Kruis", - "e.g., MacBook Pro, iPhone": "bijv. MacBook Pro, iPhone", - "log in": "inloggen", - "login using a recovery code": "inloggen met een herstelcode", - "login using an authentication code": "inloggen met een authenticatiecode", - "or you can": "of je kunt", - "or, enter the code manually": "of voer de code handmatig in", - ":count certificate selected|:count certificates selected": ":count certificaat geselecteerd|:count certificaten geselecteerd", - "Language": "Taal", - "Choose the language used throughout the application": "Kies de taal die in de hele applicatie wordt gebruikt", - "English": "Engels", - "Dutch": "Nederlands" + "Your email address is unverified.": "Je e-mailadres is niet geverifieerd." } diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index f8caf95..9e7a835 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -1,10 +1,10 @@ - + @include('partials.head') - - + + @@ -21,20 +21,15 @@ {{ __('Categories') }} + + {{ __('Shares') }} + - - {{ __('Repository') }} - - - - {{ __('Documentation') }} - - diff --git a/resources/views/mail/share-created.blade.php b/resources/views/mail/share-created.blade.php new file mode 100644 index 0000000..e0054f6 --- /dev/null +++ b/resources/views/mail/share-created.blade.php @@ -0,0 +1,22 @@ + +# {{ __('Certificates shared with you') }} + +{{ trans_choice(':name has shared :count certificate with you.|:name has shared :count certificates with you.', $certificateCount, ['name' => $senderName, 'count' => $certificateCount]) }} + +{{ __('You can view and download them on a secure page:') }} + + +{{ __('View shared certificates') }} + + +@if ($hasPassword) +{{ __('The page is protected with a password. :name will share it with you through a separate channel.', ['name' => $senderName]) }} +@endif + +{{ __('This page is available until :date, after which it will stop working.', ['date' => $expiresAt]) }} + +{{ __('If you did not expect this email, you can safely ignore it.') }} + +{{ __('Thanks') }},
+{{ config('app.name') }} +
diff --git a/resources/views/mail/shared-certificate-links.blade.php b/resources/views/mail/shared-certificate-links.blade.php deleted file mode 100644 index 52650c7..0000000 --- a/resources/views/mail/shared-certificate-links.blade.php +++ /dev/null @@ -1,18 +0,0 @@ - -# {{ __('Certificates shared with you') }} - -{{ __(':name has shared the following certificates with you.', ['name' => $senderName]) }} - -@foreach ($links as $link) - -{{ $link['title'] }} - -@endforeach - -{{ __('These links are valid until :date, after which they will stop working.', ['date' => $expiresAt]) }} - -{{ __('If you did not expect this email, you can safely ignore it.') }} - -{{ __('Thanks') }},
-{{ config('app.name') }} -
diff --git a/resources/views/mail/shared-certificates.blade.php b/resources/views/mail/shared-certificates.blade.php deleted file mode 100644 index d190b17..0000000 --- a/resources/views/mail/shared-certificates.blade.php +++ /dev/null @@ -1,12 +0,0 @@ - -# {{ __('Certificates shared with you') }} - -{{ __(':name has shared certificates with you as a password-protected ZIP archive attached to this email.', ['name' => $senderName]) }} - -{{ __('For your security, the password is sent separately through another channel (for example SMS or WhatsApp). You will need it to open the archive.') }} - -{{ __('If you did not expect this email, you can safely ignore it.') }} - -{{ __('Thanks') }},
-{{ config('app.name') }} -
diff --git a/resources/views/pages/certificates/⚡index.blade.php b/resources/views/pages/certificates/⚡index.blade.php index 4e963bc..f97fedb 100644 --- a/resources/views/pages/certificates/⚡index.blade.php +++ b/resources/views/pages/certificates/⚡index.blade.php @@ -1,10 +1,9 @@ validate([ - 'shareEmail' => ['required', 'email'], - 'shareMethod' => ['required', Rule::in(['link', 'zip'])], + 'shareEmail' => ['nullable', 'email'], + 'shareExpiresInDays' => ['required', Rule::in([1, 7, 30])], + 'sharePasswordProtect' => ['boolean'], 'selected' => ['required', 'array', 'min:1'], 'selected.*' => ['integer'], ]); - // Throttle to protect the mail server from being used as a relay. - $key = 'share-certificates:'.Auth::id(); + // Throttle emails to protect the mail server from being used as a relay. + if (filled($validated['shareEmail'])) { + $key = 'share-certificates:'.Auth::id(); - if (RateLimiter::tooManyAttempts($key, maxAttempts: 10)) { - $this->addError('shareEmail', __('Too many share attempts. Please try again later.')); + if (RateLimiter::tooManyAttempts($key, maxAttempts: 10)) { + $this->addError('shareEmail', __('Too many share attempts. Please try again later.')); - return; + return; + } + + RateLimiter::hit($key, decaySeconds: 3600); } - RateLimiter::hit($key, decaySeconds: 3600); - $certificates = Certificate::whereIn('id', $validated['selected']) ->whereNotNull('file_path') ->get(); @@ -109,56 +114,39 @@ new #[Title('Certificates')] class extends Component { return; } - $validated['shareMethod'] === 'zip' - ? $this->shareAsZip($certificates, $validated['shareEmail']) - : $this->shareAsLinks($certificates, $validated['shareEmail']); - } + $password = $validated['sharePasswordProtect'] ? Str::password(12, symbols: false) : null; - /** - * @param Collection $certificates - */ - private function shareAsLinks(Collection $certificates, string $email): void - { - $expiresAt = now()->addHours(48); + $share = Share::create([ + 'token' => Str::random(48), + 'recipient_email' => $validated['shareEmail'] ?: null, + 'password' => $password, + 'expires_at' => now()->addDays($validated['shareExpiresInDays']), + ]); - $links = $certificates->map(fn (Certificate $certificate) => [ - 'title' => $certificate->title, - 'url' => UrlGenerator::temporarySignedRoute('shared.certificate', $expiresAt, ['certificate' => $certificate->id]), - ])->all(); + $share->certificates()->attach($certificates->modelKeys()); - Mail::to($email)->send(new SharedCertificateLinksMail( - senderName: Auth::user()->name, - links: $links, - expiresAt: $expiresAt->toDayDateTimeString(), - )); - - Flux::modals()->close(); - Flux::toast(variant: 'success', text: __('Secure links sent to :email.', ['email' => $email])); - $this->reset('selected', 'shareEmail'); - } - - /** - * @param Collection $certificates - */ - private function shareAsZip(Collection $certificates, string $email): void - { - $password = Str::password(12, symbols: false); - $zipPath = app(CertificateZipper::class)->create($certificates, $password); - - try { - Mail::to($email)->send(new SharedCertificatesMail( + if (filled($validated['shareEmail'])) { + Mail::to($validated['shareEmail'])->send(new ShareCreatedMail( senderName: Auth::user()->name, - zipPath: $zipPath, + shareUrl: $share->url(), + expiresAt: $share->expires_at->toDayDateTimeString(), + certificateCount: $certificates->count(), + hasPassword: $password !== null, )); - } finally { - @unlink($zipPath); } - // Show the password so the user can share it via a separate channel. - $this->sharePassword = $password; + // Show the link (and password) so the user can pass them on; the + // password should travel through a separate channel than the link. + $this->createdShareUrl = $share->url(); + $this->createdSharePassword = $password; $this->reset('selected', 'shareEmail'); Flux::modal('share')->close(); - Flux::modal('share-password')->show(); + Flux::modal('share-created')->show(); + } + + public function dismissCreatedShare(): void + { + $this->reset('createdShareUrl', 'createdSharePassword'); } /** @@ -209,7 +197,7 @@ new #[Title('Certificates')] class extends Component { @if (count($selected) > 0) - + {{ trans_choice(':count certificate selected|:count certificates selected', count($selected), ['count' => count($selected)]) }} {{ __('Share') }} @@ -218,8 +206,10 @@ new #[Title('Certificates')] class extends Component { @endif @if ($this->certificates->isEmpty()) - - + + + + {{ __('No certificates found') }} {{ $categoryFilter !== '' @@ -228,6 +218,7 @@ new #[Title('Certificates')] class extends Component { @else +
@@ -302,6 +293,7 @@ new #[Title('Certificates')] class extends Component { @endforeach +
@endif {{-- Share modal --}} @@ -309,42 +301,63 @@ new #[Title('Certificates')] class extends Component {
{{ __('Share certificates') }} - {{ __('Send the selected certificates to an external recipient.') }} + {{ __('Create a secure page where the recipient can view and download the selected certificates.') }}
- + + {{ __('1 day') }} + {{ __('7 days') }} + {{ __('30 days') }} + - - - - + + +
{{ __('Cancel') }} - {{ __('Send') }} + {{ __('Create share') }}
- {{-- ZIP password reveal modal --}} - + {{-- Share created modal --}} +
- {{ __('Archive password') }} + {{ __('Share created') }} - {{ __('The ZIP was emailed. Share this password with the recipient through a different channel (SMS or WhatsApp). It will not be shown again.') }} + {{ __('The recipient can view and download the certificates on this page:') }}
- @if ($sharePassword) - + @if ($createdShareUrl) + + @endif + + @if ($createdSharePassword) + + + {{ __('Share this password with the recipient through a different channel (SMS or WhatsApp). You can look it up later on the Shares page.') }} + @endif
- {{ __('Done') }} + + {{ __('Done') }} +
diff --git a/resources/views/pages/shares/⚡index.blade.php b/resources/views/pages/shares/⚡index.blade.php new file mode 100644 index 0000000..a66ec51 --- /dev/null +++ b/resources/views/pages/shares/⚡index.blade.php @@ -0,0 +1,194 @@ +revoked_at === null) { + $share->update(['revoked_at' => now()]); + } + + Flux::modals()->close(); + Flux::toast(variant: 'success', text: __('Share revoked. The link no longer works.')); + } + + public function delete(int $shareId): void + { + Share::findOrFail($shareId)->delete(); + + Flux::modals()->close(); + Flux::toast(variant: 'success', text: __('Share deleted.')); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function shares(): LengthAwarePaginator + { + return Share::query() + ->with('certificates') + ->latest() + ->paginate(10); + } +}; ?> + +
+
+ {{ __('Shares') }} + {{ __('Secure pages you have shared with external recipients.') }} +
+ + @if ($this->shares->isEmpty()) + + + + + {{ __('No shares yet') }} + {{ __('Select certificates on the Certificates page and choose Share to create a secure page.') }} + + {{ __('Go to certificates') }} + + + @else +
+ + + {{ __('Certificates') }} + {{ __('Recipient') }} + {{ __('Expires') }} + {{ __('Opened') }} + {{ __('Status') }} + + + + + @foreach ($this->shares as $share) + + + + {{ $share->certificates->pluck('title')->join(', ') ?: '—' }} + + + {{ $share->recipient_email ?? '—' }} + {{ $share->expires_at->format('M j, Y H:i') }} + + {{ trans_choice(':count time|:count times', $share->access_count, ['count' => $share->access_count]) }} + + + @if ($share->revoked_at !== null) + {{ __('Revoked') }} + @elseif ($share->expires_at->isPast()) + {{ __('Expired') }} + @else + {{ __('Active') }} + @endif + + + + + + + {{ __('Link & password') }} + + + {{ __('Open page') }} + + @if ($share->isActive()) + + {{ __('Revoke') }} + + @endif + + {{ __('Delete') }} + + + + + +
+
+ {{ __('Share details') }} + + {{ __('Send the link and the password through different channels.') }} + +
+ + + + @if ($share->password !== null) + + @else + {{ __('This share is not password protected.') }} + @endif + + @if ($share->last_accessed_at !== null) + + {{ __('Last opened :date.', ['date' => $share->last_accessed_at->translatedFormat('F j, Y H:i')]) }} + + @endif + +
+ + {{ __('Done') }} + +
+
+
+ + +
+
+ {{ __('Revoke share?') }} + + {{ __('The link stops working immediately. The recipient will no longer be able to view or download the certificates.') }} + +
+
+ + {{ __('Cancel') }} + + + {{ __('Revoke') }} + +
+
+
+ + +
+
+ {{ __('Delete share?') }} + + {{ __('The link stops working and the share disappears from this list. The certificates themselves are not deleted.') }} + +
+
+ + {{ __('Cancel') }} + + + {{ __('Delete') }} + +
+
+
+
+
+ @endforeach +
+
+
+ @endif +
diff --git a/resources/views/shared/expired.blade.php b/resources/views/shared/expired.blade.php new file mode 100644 index 0000000..aca2b6d --- /dev/null +++ b/resources/views/shared/expired.blade.php @@ -0,0 +1,13 @@ +@extends('shared.layout') + +@section('content') + + + + + {{ __('This share is no longer available') }} + + {{ __('The link has expired or was revoked by the sender. Ask them to share the certificates again if you still need them.') }} + + +@endsection diff --git a/resources/views/shared/layout.blade.php b/resources/views/shared/layout.blade.php new file mode 100644 index 0000000..1c58cf8 --- /dev/null +++ b/resources/views/shared/layout.blade.php @@ -0,0 +1,21 @@ + + + + @include('partials.head', ['title' => $title ?? __('Shared certificates')]) + + +
+
+ +
+ + @yield('content') + + + {{ __('Shared securely via :app.', ['app' => config('app.name')]) }} + +
+ + @fluxScripts + + diff --git a/resources/views/shared/show.blade.php b/resources/views/shared/show.blade.php new file mode 100644 index 0000000..acfbe13 --- /dev/null +++ b/resources/views/shared/show.blade.php @@ -0,0 +1,63 @@ +@extends('shared.layout') + +@section('content') + +
+ {{ __('Certificates shared with you') }} + + {{ trans_choice(':name has shared :count certificate with you.|:name has shared :count certificates with you.', $certificates->count(), ['name' => $share->user->name, 'count' => $certificates->count()]) }} + +
+ +
+ @forelse ($certificates as $certificate) +
+
+ + + +
+ {{ $certificate->title }} + @if ($certificate->issuer) + {{ $certificate->issuer }} + @endif +
+
+ + {{ __('Download') }} + +
+ @empty +
+ {{ __('There are no files available in this share anymore.') }} +
+ @endforelse +
+ + @if ($certificates->isNotEmpty()) +
+ + {{ __('Download all as ZIP') }} + + @if ($share->password !== null) + + {{ __('The ZIP archive is encrypted with the same password you used to open this page.') }} + + @endif +
+ @endif + + + {{ __('This page is available until :date, after which it will stop working.', ['date' => $share->expires_at->translatedFormat('F j, Y H:i')]) }} + +
+@endsection diff --git a/resources/views/shared/unlock.blade.php b/resources/views/shared/unlock.blade.php new file mode 100644 index 0000000..5f0e4d9 --- /dev/null +++ b/resources/views/shared/unlock.blade.php @@ -0,0 +1,33 @@ +@extends('shared.layout') + +@section('content') + +
+ {{ __('This share is password protected') }} + + {{ __('Enter the password you received from the sender to view the shared certificates.') }} + +
+ +
+ @csrf + + + + @error('password') + {{ $message }} + @enderror + + + {{ __('Unlock') }} + + +
+@endsection diff --git a/routes/web.php b/routes/web.php index 29f7deb..f496e0b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,7 +3,7 @@ use App\Http\Controllers\CertificateDownloadController; use App\Http\Controllers\LanguageController; use App\Http\Controllers\MarketingController; -use App\Http\Controllers\SharedCertificateController; +use App\Http\Controllers\SharePageController; use Illuminate\Support\Facades\Route; Route::get('/', [MarketingController::class, 'home'])->name('home'); @@ -22,11 +22,17 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('certificates/{certificate}/download', CertificateDownloadController::class)->name('certificates.download'); Route::livewire('categories', 'pages::categories.index')->name('categories.index'); + + Route::livewire('shares', 'pages::shares.index')->name('shares.index'); }); -// External recipients: authorized by the signed URL, not by login. -Route::get('shared/certificates/{certificate}', SharedCertificateController::class) - ->middleware('signed') - ->name('shared.certificate'); +// External recipients: authorized by the unguessable share token (and the +// optional share password), not by login. +Route::prefix('shared/{share:token}')->name('shared.')->scopeBindings()->group(function () { + Route::get('/', [SharePageController::class, 'show'])->name('show'); + Route::post('unlock', [SharePageController::class, 'unlock'])->middleware('throttle:10,1')->name('unlock'); + Route::get('zip', [SharePageController::class, 'zip'])->name('zip'); + Route::get('certificates/{certificate}', [SharePageController::class, 'download'])->name('certificate'); +}); require __DIR__.'/settings.php'; diff --git a/tests/Feature/Certificates/CertificateSharingTest.php b/tests/Feature/Certificates/CertificateSharingTest.php index b1aa851..e7f0f34 100644 --- a/tests/Feature/Certificates/CertificateSharingTest.php +++ b/tests/Feature/Certificates/CertificateSharingTest.php @@ -1,13 +1,12 @@ for($user)->create(['file_path' => $path]); } -test('sharing by secure link emails the recipient', function () { - $user = User::factory()->create(); - $certificate = sharableCertificate($user); - - $this->actingAs($user); - - Livewire::test('pages::certificates.index') - ->set('selected', [$certificate->id]) - ->set('shareEmail', 'friend@example.com') - ->set('shareMethod', 'link') - ->call('share') - ->assertHasNoErrors(); - - Mail::assertSent(SharedCertificateLinksMail::class, fn ($mail) => $mail->hasTo('friend@example.com')); -}); - -test('sharing by zip emails an attachment and reveals a password', function () { +test('creating a share stores it and reveals the link and password', function () { $user = User::factory()->create(); $certificate = sharableCertificate($user); @@ -47,29 +30,81 @@ test('sharing by zip emails an attachment and reveals a password', function () { $component = Livewire::test('pages::certificates.index') ->set('selected', [$certificate->id]) - ->set('shareEmail', 'friend@example.com') - ->set('shareMethod', 'zip') + ->set('shareExpiresInDays', 7) + ->set('sharePasswordProtect', true) ->call('share') ->assertHasNoErrors(); - expect($component->get('sharePassword'))->toHaveLength(12); + $share = Share::first(); - Mail::assertSent(SharedCertificatesMail::class, fn ($mail) => $mail->hasTo('friend@example.com')); -}); - -test('sharing requires a recipient and a selection', function () { - $this->actingAs(User::factory()->create()); - - Livewire::test('pages::certificates.index') - ->set('shareEmail', '') - ->set('selected', []) - ->call('share') - ->assertHasErrors(['shareEmail', 'selected']); + expect($share)->not->toBeNull() + ->and($share->certificates()->count())->toBe(1) + ->and($share->password)->toHaveLength(12) + ->and($share->expires_at->isSameDay(now()->addDays(7)))->toBeTrue() + ->and($component->get('createdShareUrl'))->toBe($share->url()) + ->and($component->get('createdSharePassword'))->toBe($share->password); Mail::assertNothingSent(); }); -test('sharing is rate limited', function () { +test('a share without password protection stores no password', function () { + $user = User::factory()->create(); + $certificate = sharableCertificate($user); + + $this->actingAs($user); + + Livewire::test('pages::certificates.index') + ->set('selected', [$certificate->id]) + ->set('sharePasswordProtect', false) + ->call('share') + ->assertHasNoErrors(); + + expect(Share::first()->password)->toBeNull(); +}); + +test('providing a recipient email sends the share link by mail', function () { + $user = User::factory()->create(); + $certificate = sharableCertificate($user); + + $this->actingAs($user); + + Livewire::test('pages::certificates.index') + ->set('selected', [$certificate->id]) + ->set('shareEmail', 'friend@example.com') + ->call('share') + ->assertHasNoErrors(); + + expect(Share::first()->recipient_email)->toBe('friend@example.com'); + + Mail::assertSent(ShareCreatedMail::class, fn ($mail) => $mail->hasTo('friend@example.com')); +}); + +test('sharing requires a selection', function () { + $this->actingAs(User::factory()->create()); + + Livewire::test('pages::certificates.index') + ->set('selected', []) + ->call('share') + ->assertHasErrors(['selected']); + + expect(Share::count())->toBe(0); +}); + +test('certificates from another user cannot be shared', function () { + $owner = User::factory()->create(); + $certificate = sharableCertificate($owner); + + $this->actingAs(User::factory()->create()); + + Livewire::test('pages::certificates.index') + ->set('selected', [$certificate->id]) + ->call('share') + ->assertHasErrors(['selected']); + + expect(Share::withoutGlobalScopes()->count())->toBe(0); +}); + +test('emailing shares is rate limited', function () { $user = User::factory()->create(); $certificate = sharableCertificate($user); @@ -87,19 +122,3 @@ test('sharing is rate limited', function () { Mail::assertNothingSent(); }); - -test('a valid signed link streams the file to an unauthenticated recipient', function () { - $user = User::factory()->create(); - $certificate = sharableCertificate($user); - - $url = URL::temporarySignedRoute('shared.certificate', now()->addHours(48), ['certificate' => $certificate->id]); - - $this->get($url)->assertOk(); -}); - -test('a tampered signed link is rejected', function () { - $user = User::factory()->create(); - $certificate = sharableCertificate($user); - - $this->get(route('shared.certificate', $certificate))->assertForbidden(); -}); diff --git a/tests/Feature/Shares/ShareManagementTest.php b/tests/Feature/Shares/ShareManagementTest.php new file mode 100644 index 0000000..4ad08e4 --- /dev/null +++ b/tests/Feature/Shares/ShareManagementTest.php @@ -0,0 +1,64 @@ +create(); + $share = Share::factory()->for($user)->create(['recipient_email' => 'client@example.com']); + + $this->actingAs($user) + ->get(route('shares.index')) + ->assertOk() + ->assertSee('client@example.com'); +}); + +test('shares from other users are hidden', function () { + Share::factory()->create(['recipient_email' => 'client@example.com']); + + $this->actingAs(User::factory()->create()) + ->get(route('shares.index')) + ->assertOk() + ->assertDontSee('client@example.com'); +}); + +test('a share can be revoked', function () { + $user = User::factory()->create(); + $share = Share::factory()->for($user)->create(); + + $this->actingAs($user); + + Livewire::test('pages::shares.index') + ->call('revoke', $share->id); + + expect($share->refresh()->revoked_at)->not->toBeNull(); +}); + +test('a share can be deleted', function () { + $user = User::factory()->create(); + $share = Share::factory()->for($user)->create(); + + $this->actingAs($user); + + Livewire::test('pages::shares.index') + ->call('delete', $share->id); + + expect(Share::withoutGlobalScopes()->count())->toBe(0); +}); + +test('another user cannot revoke a share they do not own', function () { + $share = Share::factory()->create(); + + $this->actingAs(User::factory()->create()); + + expect(fn () => Livewire::test('pages::shares.index')->call('revoke', $share->id)) + ->toThrow(ModelNotFoundException::class); + + expect($share->refresh()->revoked_at)->toBeNull(); +}); + +test('guests are redirected to login', function () { + $this->get(route('shares.index'))->assertRedirect(route('login')); +}); diff --git a/tests/Feature/Shares/SharePageTest.php b/tests/Feature/Shares/SharePageTest.php new file mode 100644 index 0000000..c1864a0 --- /dev/null +++ b/tests/Feature/Shares/SharePageTest.php @@ -0,0 +1,139 @@ +create(); + + $path = 'certificates/'.$user->id.'/'.uniqid().'.pdf'; + Storage::disk('local')->put($path, 'file-contents'); + + $certificate = Certificate::factory()->for($user)->create([ + 'title' => 'Forklift License', + 'file_path' => $path, + ]); + + $share = Share::factory()->for($user)->state($shareState)->create(); + $share->certificates()->attach($certificate); + + return $share; +} + +test('an active share page lists the certificates for an unauthenticated visitor', function () { + $share = shareWithCertificate(); + + $this->get(route('shared.show', $share->token)) + ->assertOk() + ->assertSee('Forklift License') + ->assertSee(__('Download all as ZIP')); +}); + +test('opening the page is recorded on the share', function () { + $share = shareWithCertificate(); + + $this->get(route('shared.show', $share->token))->assertOk(); + $this->get(route('shared.show', $share->token))->assertOk(); + + $share->refresh(); + + expect($share->access_count)->toBe(2) + ->and($share->last_accessed_at)->not->toBeNull(); +}); + +test('an unknown token is not found', function () { + $this->get(route('shared.show', 'nonexistent-token'))->assertNotFound(); +}); + +test('an expired share renders the gone page', function () { + $share = shareWithCertificate(['expires_at' => now()->subDay()]); + + $this->get(route('shared.show', $share->token)) + ->assertGone() + ->assertSee(__('This share is no longer available')); +}); + +test('a revoked share renders the gone page', function () { + $share = shareWithCertificate(['revoked_at' => now()->subHour()]); + + $this->get(route('shared.show', $share->token))->assertGone(); +}); + +test('a password protected share asks for the password first', function () { + $share = shareWithCertificate(['password' => 'top-secret-123']); + + $this->get(route('shared.show', $share->token)) + ->assertOk() + ->assertSee(__('This share is password protected')) + ->assertDontSee('Forklift License'); +}); + +test('the correct password unlocks the share for the session', function () { + $share = shareWithCertificate(['password' => 'top-secret-123']); + + $this->post(route('shared.unlock', $share->token), ['password' => 'top-secret-123']) + ->assertRedirect(route('shared.show', $share->token)); + + $this->get(route('shared.show', $share->token)) + ->assertOk() + ->assertSee('Forklift License'); +}); + +test('a wrong password does not unlock the share', function () { + $share = shareWithCertificate(['password' => 'top-secret-123']); + + $this->post(route('shared.unlock', $share->token), ['password' => 'wrong']) + ->assertSessionHasErrors('password'); + + $this->get(route('shared.show', $share->token))->assertDontSee('Forklift License'); +}); + +test('a certificate in the share can be downloaded', function () { + $share = shareWithCertificate(); + $certificate = $share->certificates->first(); + + $this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $certificate->id])) + ->assertOk() + ->assertDownload('Forklift License.pdf'); +}); + +test('a certificate outside the share cannot be downloaded through it', function () { + $share = shareWithCertificate(); + $other = Certificate::factory()->create(['file_path' => 'certificates/other.pdf']); + + $this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $other->id])) + ->assertNotFound(); +}); + +test('downloads are blocked while a password protected share is locked', function () { + $share = shareWithCertificate(['password' => 'top-secret-123']); + $certificate = $share->certificates->first(); + + $this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $certificate->id])) + ->assertForbidden(); + + $this->get(route('shared.zip', $share->token))->assertForbidden(); +}); + +test('downloads are blocked once the share expires', function () { + $share = shareWithCertificate(['expires_at' => now()->subDay()]); + $certificate = $share->certificates->first(); + + $this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $certificate->id])) + ->assertGone(); +}); + +test('the zip download returns an archive', function () { + $share = shareWithCertificate(); + + $this->get(route('shared.zip', $share->token)) + ->assertOk() + ->assertDownload('certificates.zip'); +});