Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/Actions/CreateVaultNodeShare.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\VaultNode;
use App\Models\VaultNodeShare;
use Illuminate\Support\Str;

final readonly class CreateVaultNodeShare
{
public function handle(VaultNode $node): VaultNodeShare
{
/** @var VaultNodeShare $share */
$share = VaultNodeShare::query()->firstOrCreate(
['vault_node_id' => $node->id],
['token' => Str::random(48)],
);

return $share;
}
}
15 changes: 15 additions & 0 deletions app/Actions/DeleteVaultNodeShare.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\VaultNode;

final readonly class DeleteVaultNodeShare
{
public function handle(VaultNode $node): void
{
$node->share()->delete();
}
}
47 changes: 47 additions & 0 deletions app/Actions/GetReferencedImageNodesFromContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\VaultNodeType;
use App\Models\VaultNode;

final readonly class GetReferencedImageNodesFromContent
{
public function __construct(
private ResolveTwoPaths $resolveTwoPaths,
private GetVaultNodeFromPath $getVaultNodeFromPath,
) {
//
}

/** @return list<VaultNode> */
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);
}
}
20 changes: 20 additions & 0 deletions app/Http/Controllers/ShareController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\VaultNodeShare;
use App\ViewModels\ShareViewModel;
use Inertia\Inertia;
use Inertia\Response;

final readonly class ShareController
{
public function show(VaultNodeShare $share): Response
{
return Inertia::render('share/Show', [
'share' => ShareViewModel::fromModel($share),
]);
}
}
48 changes: 48 additions & 0 deletions app/Http/Controllers/ShareFileController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\GetPathFromVaultNode;
use App\Actions\GetReferencedImageNodesFromContent;
use App\Actions\GetVaultNodeFromPath;
use App\Models\VaultNode;
use App\Models\VaultNodeShare;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;

final readonly class ShareFileController
{
public function show(
Request $request,
VaultNodeShare $share,
GetVaultNodeFromPath $getVaultNodeFromPath,
GetReferencedImageNodesFromContent $getReferencedImageNodesFromContent,
GetPathFromVaultNode $getPathFromVaultNode,
): BinaryFileResponse {
abort_unless($request->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);
}
}
2 changes: 2 additions & 0 deletions app/Http/Controllers/VaultController.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ public function show(
abort_unless($file !== null, 404);
}

$file->load('share');

$data = [
...$data,
'openedFile' => [
Expand Down
51 changes: 51 additions & 0 deletions app/Http/Controllers/VaultNodeShareController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreateVaultNodeShare;
use App\Actions\DeleteVaultNodeShare;
use App\Enums\VaultNodeType;
use App\Models\User;
use App\Models\Vault;
use App\Models\VaultNode;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class VaultNodeShareController
{
public function store(
Vault $vault,
VaultNode $node,
#[CurrentUser] User $user,
CreateVaultNodeShare $createVaultNodeShare,
): JsonResponse {
abort_unless($user->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,
]);
}
}
8 changes: 8 additions & 0 deletions app/Models/VaultNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +39,7 @@
* @property-read Collection<int, VaultNode> $links
* @property-read Collection<int, VaultNode> $backlinks
* @property-read Collection<int, Tag> $tags
* @property-read VaultNodeShare|null $share
*/
final class VaultNode extends Model
{
Expand Down Expand Up @@ -80,6 +82,12 @@ public function tags(): BelongsToMany
->withPivot('position');
}

/** @return HasOne<VaultNodeShare, $this> */
public function share(): HasOne
{
return $this->hasOne(VaultNodeShare::class);
}

public function isTemplate(): bool
{
return $this->vault->templates_node_id !== null
Expand Down
31 changes: 31 additions & 0 deletions app/Models/VaultNodeShare.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Carbon\CarbonImmutable;
use Database\Factories\VaultNodeShareFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
* @property-read int $id
* @property-read int $vault_node_id
* @property-read string $token
* @property-read CarbonImmutable $created_at
* @property-read CarbonImmutable $updated_at
* @property-read VaultNode $node
*/
final class VaultNodeShare extends Model
{
/** @use HasFactory<VaultNodeShareFactory> */
use HasFactory;

/** @return BelongsTo<VaultNode, $this> */
public function node(): BelongsTo
{
return $this->belongsTo(VaultNode::class, 'vault_node_id');
}
}
15 changes: 15 additions & 0 deletions app/Policies/VaultNodePolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
30 changes: 30 additions & 0 deletions app/ViewModels/ShareViewModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\ViewModels;

use App\Models\VaultNodeShare;
use Carbon\CarbonImmutable;

final readonly class ShareViewModel
{
public function __construct(
public string $token,
public string $name,
public ?string $content,
public ?CarbonImmutable $updated_at,
) {
//
}

public static function fromModel(VaultNodeShare $share): self
{
return new self(
$share->token,
$share->node->name,
$share->node->content,
$share->node->updated_at,
);
}
}
5 changes: 5 additions & 0 deletions app/ViewModels/VaultNodeViewModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
//
Expand All @@ -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,
Expand All @@ -43,6 +47,7 @@ public static function fromModel(VaultNode $node): self
$fullPath,
$url,
$node->content,
$shareUrl,
$node->updated_at,
);
}
Expand Down
28 changes: 28 additions & 0 deletions database/factories/VaultNodeShareFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\VaultNode;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\VaultNodeShare>
*/
final class VaultNodeShareFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'vault_node_id' => VaultNode::factory(),
'token' => Str::random(48),
];
}
}
Loading