From 9b5b6b8cf637e28dc0be0eb55ea5dee242549790 Mon Sep 17 00:00:00 2001 From: Anton Bezruchkin Date: Wed, 5 Aug 2026 21:38:33 +0000 Subject: [PATCH] feat: read-only shareable note links --- app/Actions/CreateVaultNodeShare.php | 23 +++ app/Actions/DeleteVaultNodeShare.php | 15 ++ .../GetReferencedImageNodesFromContent.php | 47 ++++++ app/Http/Controllers/ShareController.php | 20 +++ app/Http/Controllers/ShareFileController.php | 48 ++++++ app/Http/Controllers/VaultController.php | 2 + .../Controllers/VaultNodeShareController.php | 51 +++++++ app/Models/VaultNode.php | 8 + app/Models/VaultNodeShare.php | 31 ++++ app/Policies/VaultNodePolicy.php | 15 ++ app/ViewModels/ShareViewModel.php | 30 ++++ app/ViewModels/VaultNodeViewModel.php | 5 + database/factories/VaultNodeShareFactory.php | 28 ++++ ..._120000_create_vault_node_shares_table.php | 23 +++ .../js/components/editor/MarkdownToolbar.vue | 28 ++++ resources/js/components/menu/GuestMenu.vue | 63 ++++++++ .../js/components/modal/NoteShareModal.vue | 140 ++++++++++++++++++ resources/js/composables/useEditor.ts | 2 + resources/js/icons/Share.vue | 17 +++ resources/js/layouts/PublicLayout.vue | 23 +++ resources/js/pages/share/Show.vue | 88 +++++++++++ resources/js/pages/vault/Show.vue | 7 +- .../services/tiptap/extension-custom-image.ts | 9 +- resources/js/types/vault.ts | 1 + routes/web.php | 11 ++ .../Web/Share/ShareFileControllerTest.php | 89 +++++++++++ tests/Feature/Web/Share/ShowShareTest.php | 99 +++++++++++++ .../Web/Vaults/CreateVaultNodeShareTest.php | 95 ++++++++++++ .../Web/Vaults/DeleteVaultNodeShareTest.php | 56 +++++++ 29 files changed, 1071 insertions(+), 3 deletions(-) create mode 100644 app/Actions/CreateVaultNodeShare.php create mode 100644 app/Actions/DeleteVaultNodeShare.php create mode 100644 app/Actions/GetReferencedImageNodesFromContent.php create mode 100644 app/Http/Controllers/ShareController.php create mode 100644 app/Http/Controllers/ShareFileController.php create mode 100644 app/Http/Controllers/VaultNodeShareController.php create mode 100644 app/Models/VaultNodeShare.php create mode 100644 app/ViewModels/ShareViewModel.php create mode 100644 database/factories/VaultNodeShareFactory.php create mode 100644 database/migrations/2025_08_26_120000_create_vault_node_shares_table.php create mode 100644 resources/js/components/menu/GuestMenu.vue create mode 100644 resources/js/components/modal/NoteShareModal.vue create mode 100644 resources/js/icons/Share.vue create mode 100644 resources/js/layouts/PublicLayout.vue create mode 100644 resources/js/pages/share/Show.vue create mode 100644 tests/Feature/Web/Share/ShareFileControllerTest.php create mode 100644 tests/Feature/Web/Share/ShowShareTest.php create mode 100644 tests/Feature/Web/Vaults/CreateVaultNodeShareTest.php create mode 100644 tests/Feature/Web/Vaults/DeleteVaultNodeShareTest.php diff --git a/app/Actions/CreateVaultNodeShare.php b/app/Actions/CreateVaultNodeShare.php new file mode 100644 index 00000000..37aacf06 --- /dev/null +++ b/app/Actions/CreateVaultNodeShare.php @@ -0,0 +1,23 @@ +firstOrCreate( + ['vault_node_id' => $node->id], + ['token' => Str::random(48)], + ); + + return $share; + } +} diff --git a/app/Actions/DeleteVaultNodeShare.php b/app/Actions/DeleteVaultNodeShare.php new file mode 100644 index 00000000..3a62d451 --- /dev/null +++ b/app/Actions/DeleteVaultNodeShare.php @@ -0,0 +1,15 @@ +share()->delete(); + } +} diff --git a/app/Actions/GetReferencedImageNodesFromContent.php b/app/Actions/GetReferencedImageNodesFromContent.php new file mode 100644 index 00000000..861fa056 --- /dev/null +++ b/app/Actions/GetReferencedImageNodesFromContent.php @@ -0,0 +1,47 @@ + */ + public function handle(VaultNode $node): array + { + /** @var string $content */ + $content = $node->content ?? ''; + + if (preg_match_all('/!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/', $content, $matches) === false) { + return []; + } + + $currentPath = $node->fullPath(); + $imageNodes = []; + + foreach (array_unique($matches[1]) as $path) { + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + continue; + } + + $resolvedPath = $this->resolveTwoPaths->handle($currentPath, $path); + $imageNode = $this->getVaultNodeFromPath->handle($node->vault_id, $resolvedPath); + + if ($imageNode !== null && $imageNode->type() === VaultNodeType::IMAGE) { + $imageNodes[$imageNode->id] = $imageNode; + } + } + + return array_values($imageNodes); + } +} diff --git a/app/Http/Controllers/ShareController.php b/app/Http/Controllers/ShareController.php new file mode 100644 index 00000000..fc0c30c6 --- /dev/null +++ b/app/Http/Controllers/ShareController.php @@ -0,0 +1,20 @@ + ShareViewModel::fromModel($share), + ]); + } +} diff --git a/app/Http/Controllers/ShareFileController.php b/app/Http/Controllers/ShareFileController.php new file mode 100644 index 00000000..14a249f4 --- /dev/null +++ b/app/Http/Controllers/ShareFileController.php @@ -0,0 +1,48 @@ +has('path'), 404); + + /** @var string $path */ + $path = $request->path; + + $node = $getVaultNodeFromPath->handle($share->node->vault_id, $path); + + abort_unless($node !== null, 404); + + // Only files actually referenced as images in the shared note's current + // content are servable, regardless of what else lives in the vault. + $allowedNodeIds = array_map( + fn(VaultNode $imageNode): int => $imageNode->id, + $getReferencedImageNodesFromContent->handle($share->node), + ); + + abort_unless(in_array($node->id, $allowedNodeIds, true), 404); + + $relativePath = $getPathFromVaultNode->handle($node); + $absolutePath = Storage::disk('local')->path($relativePath); + + return response()->file($absolutePath); + } +} diff --git a/app/Http/Controllers/VaultController.php b/app/Http/Controllers/VaultController.php index 233c731f..8201641f 100644 --- a/app/Http/Controllers/VaultController.php +++ b/app/Http/Controllers/VaultController.php @@ -78,6 +78,8 @@ public function show( abort_unless($file !== null, 404); } + $file->load('share'); + $data = [ ...$data, 'openedFile' => [ diff --git a/app/Http/Controllers/VaultNodeShareController.php b/app/Http/Controllers/VaultNodeShareController.php new file mode 100644 index 00000000..309ad4d7 --- /dev/null +++ b/app/Http/Controllers/VaultNodeShareController.php @@ -0,0 +1,51 @@ +can('share', $node), 403); + abort_unless($node->is_file && $node->type() === VaultNodeType::NOTE, 422); + + $share = $createVaultNodeShare->handle($node); + + return response()->json([ + 'data' => [ + 'token' => $share->token, + 'url' => route('share.show', ['share' => $share->token]), + ], + ]); + } + + public function destroy( + Vault $vault, + VaultNode $node, + #[CurrentUser] User $user, + DeleteVaultNodeShare $deleteVaultNodeShare, + ): JsonResponse { + abort_unless($user->can('share', $node), 403); + + $deleteVaultNodeShare->handle($node); + + return response()->json([ + 'data' => null, + ]); + } +} diff --git a/app/Models/VaultNode.php b/app/Models/VaultNode.php index 22e9eb96..05a4ee31 100644 --- a/app/Models/VaultNode.php +++ b/app/Models/VaultNode.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Laravel\Scout\Searchable; use Override; use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships; @@ -38,6 +39,7 @@ * @property-read Collection $links * @property-read Collection $backlinks * @property-read Collection $tags + * @property-read VaultNodeShare|null $share */ final class VaultNode extends Model { @@ -80,6 +82,12 @@ public function tags(): BelongsToMany ->withPivot('position'); } + /** @return HasOne */ + public function share(): HasOne + { + return $this->hasOne(VaultNodeShare::class); + } + public function isTemplate(): bool { return $this->vault->templates_node_id !== null diff --git a/app/Models/VaultNodeShare.php b/app/Models/VaultNodeShare.php new file mode 100644 index 00000000..ea2ffd74 --- /dev/null +++ b/app/Models/VaultNodeShare.php @@ -0,0 +1,31 @@ + */ + use HasFactory; + + /** @return BelongsTo */ + public function node(): BelongsTo + { + return $this->belongsTo(VaultNode::class, 'vault_node_id'); + } +} diff --git a/app/Policies/VaultNodePolicy.php b/app/Policies/VaultNodePolicy.php index a93807a5..aa7040d0 100644 --- a/app/Policies/VaultNodePolicy.php +++ b/app/Policies/VaultNodePolicy.php @@ -24,4 +24,19 @@ public function delete(User $user, VaultNode $node): bool ->wherePivot('accepted', true) ->exists(); } + + /** + * Determine whether the user can create or revoke a public share link for the model. + */ + public function share(User $user, VaultNode $node): bool + { + /** @var Vault $vault */ + $vault = $node->vault; + + return $user->id === $vault->created_by || + $vault->collaborators() + ->wherePivot('user_id', $user->id) + ->wherePivot('accepted', true) + ->exists(); + } } diff --git a/app/ViewModels/ShareViewModel.php b/app/ViewModels/ShareViewModel.php new file mode 100644 index 00000000..dc86f8b4 --- /dev/null +++ b/app/ViewModels/ShareViewModel.php @@ -0,0 +1,30 @@ +token, + $share->node->name, + $share->node->content, + $share->node->updated_at, + ); + } +} diff --git a/app/ViewModels/VaultNodeViewModel.php b/app/ViewModels/VaultNodeViewModel.php index eab4c511..ba2856c3 100644 --- a/app/ViewModels/VaultNodeViewModel.php +++ b/app/ViewModels/VaultNodeViewModel.php @@ -21,6 +21,7 @@ public function __construct( public string $full_path, public string $url, public ?string $content, + public ?string $share_url, public ?CarbonImmutable $updated_at, ) { // @@ -31,6 +32,9 @@ public static function fromModel(VaultNode $node): self $extension = $node->extension ? ".{$node->extension}" : ''; $fullPath = "/{$node->fullPath()}{$extension}"; $url = $node->is_file ? app(GetUrlFromVaultNode::class)->handle($node) : ''; + $shareUrl = $node->relationLoaded('share') && $node->share !== null + ? route('share.show', ['share' => $node->share->token]) + : null; return new self( $node->id, @@ -43,6 +47,7 @@ public static function fromModel(VaultNode $node): self $fullPath, $url, $node->content, + $shareUrl, $node->updated_at, ); } diff --git a/database/factories/VaultNodeShareFactory.php b/database/factories/VaultNodeShareFactory.php new file mode 100644 index 00000000..07e09d91 --- /dev/null +++ b/database/factories/VaultNodeShareFactory.php @@ -0,0 +1,28 @@ + + */ +final class VaultNodeShareFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'vault_node_id' => VaultNode::factory(), + 'token' => Str::random(48), + ]; + } +} diff --git a/database/migrations/2025_08_26_120000_create_vault_node_shares_table.php b/database/migrations/2025_08_26_120000_create_vault_node_shares_table.php new file mode 100644 index 00000000..d9459dd7 --- /dev/null +++ b/database/migrations/2025_08_26_120000_create_vault_node_shares_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('vault_node_id')->unique()->constrained('vault_nodes')->cascadeOnDelete(); + $table->string('token', 48)->unique(); + $table->timestamps(); + }); + } +}; diff --git a/resources/js/components/editor/MarkdownToolbar.vue b/resources/js/components/editor/MarkdownToolbar.vue index c6ae5980..6c7ac950 100644 --- a/resources/js/components/editor/MarkdownToolbar.vue +++ b/resources/js/components/editor/MarkdownToolbar.vue @@ -1,6 +1,7 @@ diff --git a/resources/js/components/menu/GuestMenu.vue b/resources/js/components/menu/GuestMenu.vue new file mode 100644 index 00000000..432ca71d --- /dev/null +++ b/resources/js/components/menu/GuestMenu.vue @@ -0,0 +1,63 @@ + + + diff --git a/resources/js/components/modal/NoteShareModal.vue b/resources/js/components/modal/NoteShareModal.vue new file mode 100644 index 00000000..f75dca66 --- /dev/null +++ b/resources/js/components/modal/NoteShareModal.vue @@ -0,0 +1,140 @@ + + + diff --git a/resources/js/composables/useEditor.ts b/resources/js/composables/useEditor.ts index d69ad418..9906973d 100644 --- a/resources/js/composables/useEditor.ts +++ b/resources/js/composables/useEditor.ts @@ -26,6 +26,7 @@ interface SetupEditorOptions { isEditMode: Readonly>; onUpdate: (markdown: string) => void; openFilePath: (path: string) => void; + imageBaseUrl?: string; } export function useEditor(options: SetupEditorOptions) { @@ -101,6 +102,7 @@ export function useEditor(options: SetupEditorOptions) { }), CustomImage.configure({ vaultId: options.vaultId, + baseUrl: options.imageBaseUrl ?? null, }), CustomLink.configure({ autolink: false, diff --git a/resources/js/icons/Share.vue b/resources/js/icons/Share.vue new file mode 100644 index 00000000..78f7c740 --- /dev/null +++ b/resources/js/icons/Share.vue @@ -0,0 +1,17 @@ + diff --git a/resources/js/layouts/PublicLayout.vue b/resources/js/layouts/PublicLayout.vue new file mode 100644 index 00000000..4fc5a38c --- /dev/null +++ b/resources/js/layouts/PublicLayout.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/js/pages/share/Show.vue b/resources/js/pages/share/Show.vue new file mode 100644 index 00000000..c4093fc3 --- /dev/null +++ b/resources/js/pages/share/Show.vue @@ -0,0 +1,88 @@ + + + diff --git a/resources/js/pages/vault/Show.vue b/resources/js/pages/vault/Show.vue index 56cb292c..794d14e7 100644 --- a/resources/js/pages/vault/Show.vue +++ b/resources/js/pages/vault/Show.vue @@ -308,7 +308,12 @@ useEcho<{ data: { user_id: number } }>( @name-updated="openedFile.file.name = $event" > ({ @@ -10,14 +11,18 @@ export const CustomImage = Image.extend({ return { ...this.parent!(), vaultId: null, + baseUrl: null, }; }, renderHTML({ HTMLAttributes }) { const { src, ...rest } = HTMLAttributes; + const baseUrl = + this.options.baseUrl ?? + (this.options.vaultId ? `/files/${this.options.vaultId}` : null); const resolvedSrc = - src && !src.startsWith('http://') && !src.startsWith('https://') && this.options.vaultId - ? `/files/${this.options.vaultId}?path=${src}` + src && !src.startsWith('http://') && !src.startsWith('https://') && baseUrl + ? `${baseUrl}?path=${src}` : src; return ['img', mergeAttributes(this.options.HTMLAttributes, { ...rest, src: resolvedSrc })]; diff --git a/resources/js/types/vault.ts b/resources/js/types/vault.ts index db6d4b9a..d1382a92 100644 --- a/resources/js/types/vault.ts +++ b/resources/js/types/vault.ts @@ -38,6 +38,7 @@ export interface VaultNode { full_path: string; url: string; content: string | null; + share_url: string | null; updated_at: string; } diff --git a/routes/web.php b/routes/web.php index cb32ec68..0eb32e4b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,8 @@ use App\Http\Controllers\PasswordController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\SettingController; +use App\Http\Controllers\ShareController; +use App\Http\Controllers\ShareFileController; use App\Http\Controllers\VaultCollaborationAcceptController; use App\Http\Controllers\VaultCollaborationController; use App\Http\Controllers\VaultCollaborationDeclineController; @@ -25,6 +27,7 @@ use App\Http\Controllers\VaultNodeController; use App\Http\Controllers\VaultNodeImportController; use App\Http\Controllers\VaultNodeMoveController; +use App\Http\Controllers\VaultNodeShareController; use App\Http\Controllers\VaultSearchController; use App\Http\Middleware\EnsureEmailIsConfigured; use App\Http\Middleware\EnsureRegistrationIsEnabled; @@ -56,6 +59,8 @@ Route::delete('', [VaultNodeController::class, 'destroy'])->name('destroy'); Route::get('children', VaultNodeChildrenController::class)->name('children'); Route::patch('move', VaultNodeMoveController::class)->name('move'); + Route::post('share', [VaultNodeShareController::class, 'store'])->name('share.store'); + Route::delete('share', [VaultNodeShareController::class, 'destroy'])->name('share.destroy'); })->scopeBindings(); Route::post('import', VaultNodeImportController::class)->name('import'); @@ -112,3 +117,9 @@ Route::get('{provider}/callback', [OAuthController::class, 'store'])->name('oauth.store'); }); }); + +// Public, unauthenticated read-only note links +Route::middleware('throttle')->prefix('share')->name('share.')->group(function (): void { + Route::get('{share:token}', [ShareController::class, 'show'])->name('show'); + Route::get('{share:token}/files', [ShareFileController::class, 'show'])->name('files'); +}); diff --git a/tests/Feature/Web/Share/ShareFileControllerTest.php b/tests/Feature/Web/Share/ShareFileControllerTest.php new file mode 100644 index 00000000..1366ea0a --- /dev/null +++ b/tests/Feature/Web/Share/ShareFileControllerTest.php @@ -0,0 +1,89 @@ +create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $image = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => 'picture', + 'extension' => 'jpg', + ]); + $imagePath = new GetPathFromVaultNode()->handle($image); + Storage::disk('local')->put($imagePath, 'binary-data'); + $imageUrl = new GetUrlFromVaultNode()->handle($image); + $imagePathParam = mb_substr($imageUrl, mb_strpos($imageUrl, 'path=') + mb_strlen('path=')); + + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => "![alt]({$imagePathParam})", + ]); + $share = new CreateVaultNodeShare()->handle($note); + + $this->get(route('share.files', ['share' => $share->token]) . '?path=' . $imagePathParam) + ->assertStatus(200); +}); + +it('does not serve a file that is not referenced in the shared note', function (): void { + $user = User::factory()->create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $unrelatedImage = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => 'unrelated', + 'extension' => 'jpg', + ]); + $imagePath = new GetPathFromVaultNode()->handle($unrelatedImage); + Storage::disk('local')->put($imagePath, 'binary-data'); + $imageUrl = new GetUrlFromVaultNode()->handle($unrelatedImage); + $imagePathParam = mb_substr($imageUrl, mb_strpos($imageUrl, 'path=') + mb_strlen('path=')); + + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'No images here.', + ]); + $share = new CreateVaultNodeShare()->handle($note); + + $this->get(route('share.files', ['share' => $share->token]) . '?path=' . $imagePathParam) + ->assertStatus(404); +}); + +it('never serves a markdown file through the share files endpoint', function (): void { + $user = User::factory()->create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $otherNote = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => 'secret', + 'extension' => 'md', + 'content' => 'Top secret content', + ]); + + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => '![alt](/secret.md)', + ]); + $share = new CreateVaultNodeShare()->handle($note); + + $this->get(route('share.files', ['share' => $share->token]) . '?path=/secret.md') + ->assertStatus(404); +}); diff --git a/tests/Feature/Web/Share/ShowShareTest.php b/tests/Feature/Web/Share/ShowShareTest.php new file mode 100644 index 00000000..24d8f289 --- /dev/null +++ b/tests/Feature/Web/Share/ShowShareTest.php @@ -0,0 +1,99 @@ +create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => 'My note', + 'extension' => 'md', + 'content' => 'Hello world', + ]); + $share = new CreateVaultNodeShare()->handle($note); + + $this->get(route('share.show', ['share' => $share->token])) + ->assertStatus(200) + ->assertInertia( + fn(Assert $page): Assert => $page + ->where('share.token', $share->token) + ->where('share.name', 'My note') + ->where('share.content', 'Hello world') + ); +}); + +it('reflects the current note content, not a snapshot', function (): void { + $user = User::factory()->create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Original content', + ]); + $share = new CreateVaultNodeShare()->handle($note); + + new UpdateVaultNode()->handle($note, ['content' => 'Updated content']); + + $this->get(route('share.show', ['share' => $share->token])) + ->assertInertia( + fn(Assert $page): Assert => $page->where('share.content', 'Updated content') + ); +}); + +it('returns a 404 error for an unknown token', function (): void { + $this->get(route('share.show', ['share' => 'unknown-token'])) + ->assertStatus(404); +}); + +it('returns a 404 error after a share link is revoked', function (): void { + $user = User::factory()->create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Hello world', + ]); + $share = new CreateVaultNodeShare()->handle($note); + $token = $share->token; + + $share->delete(); + + $this->get(route('share.show', ['share' => $token])) + ->assertStatus(404); +}); + +it('returns a 404 error after the shared note is deleted', function (): void { + $user = User::factory()->create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Hello world', + ]); + $share = new CreateVaultNodeShare()->handle($note); + $token = $share->token; + + $note->delete(); + + $this->get(route('share.show', ['share' => $token])) + ->assertStatus(404); +}); diff --git a/tests/Feature/Web/Vaults/CreateVaultNodeShareTest.php b/tests/Feature/Web/Vaults/CreateVaultNodeShareTest.php new file mode 100644 index 00000000..84de2561 --- /dev/null +++ b/tests/Feature/Web/Vaults/CreateVaultNodeShareTest.php @@ -0,0 +1,95 @@ +create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Hello world', + ]); + + $this->actingAs($user); + + $response = $this->post( + route('vaults.nodes.share.store', ['vault' => $vault->id, 'node' => $note->id]), + ); + + $response->assertStatus(200); + $response->assertJsonStructure(['data' => ['token', 'url']]); + expect($note->fresh()->share)->not->toBeNull(); +}); + +it('returns the same token when a note is already shared', function (): void { + $user = User::factory()->create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Hello world', + ]); + + $this->actingAs($user); + + $firstResponse = $this->post( + route('vaults.nodes.share.store', ['vault' => $vault->id, 'node' => $note->id]), + ); + $secondResponse = $this->post( + route('vaults.nodes.share.store', ['vault' => $vault->id, 'node' => $note->id]), + ); + + expect($firstResponse->json('data.token'))->toBe($secondResponse->json('data.token')); +}); + +it('does not allow a collaborator without access to share a note', function (): void { + [$user1, $user2] = User::factory(2)->create(); + $vault = new CreateVault()->handle($user1, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Hello world', + ]); + + $this->actingAs($user2); + + $response = $this->post( + route('vaults.nodes.share.store', ['vault' => $vault->id, 'node' => $note->id]), + ); + + $response->assertStatus(403); + expect($note->fresh()->share)->toBeNull(); +}); + +it('does not allow sharing a folder', function (): void { + $user = User::factory()->create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $folder = new CreateVaultNode()->handle($vault, [ + 'is_file' => false, + 'name' => fake()->words(3, true), + ]); + + $this->actingAs($user); + + $response = $this->post( + route('vaults.nodes.share.store', ['vault' => $vault->id, 'node' => $folder->id]), + ); + + $response->assertStatus(422); +}); diff --git a/tests/Feature/Web/Vaults/DeleteVaultNodeShareTest.php b/tests/Feature/Web/Vaults/DeleteVaultNodeShareTest.php new file mode 100644 index 00000000..2615b2ce --- /dev/null +++ b/tests/Feature/Web/Vaults/DeleteVaultNodeShareTest.php @@ -0,0 +1,56 @@ +create(); + $vault = new CreateVault()->handle($user, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Hello world', + ]); + $share = new CreateVaultNodeShare()->handle($note); + + $this->actingAs($user); + + $response = $this->delete( + route('vaults.nodes.share.destroy', ['vault' => $vault->id, 'node' => $note->id]), + ); + + $response->assertStatus(200); + expect($note->fresh()->share)->toBeNull(); + + $this->get(route('share.show', ['share' => $share->token]))->assertStatus(404); +}); + +it('does not allow a user without access to revoke a share link', function (): void { + [$user1, $user2] = User::factory(2)->create(); + $vault = new CreateVault()->handle($user1, [ + 'name' => fake()->words(3, true), + ]); + $note = new CreateVaultNode()->handle($vault, [ + 'is_file' => true, + 'name' => fake()->words(3, true), + 'extension' => 'md', + 'content' => 'Hello world', + ]); + new CreateVaultNodeShare()->handle($note); + + $this->actingAs($user2); + + $response = $this->delete( + route('vaults.nodes.share.destroy', ['vault' => $vault->id, 'node' => $note->id]), + ); + + $response->assertStatus(403); + expect($note->fresh()->share)->not->toBeNull(); +});