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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,19 @@ NIGHTWATCH_TOKEN=
# TELEGRAM_ENABLED=true

# Media Services
# Read-only WebDAV share offered as a media source in the editor (Nextcloud,
# ownCloud, ...). WEBDAV_URL points at the collection to browse, for example
# https://cloud.example.com/remote.php/dav/files/<user>
WEBDAV_URL=
WEBDAV_USERNAME=
WEBDAV_PASSWORD=
# Optional folder inside the share to confine browsing to.
WEBDAV_ROOT=
# Name of the tab in the media picker.
WEBDAV_LABEL="Files"
# Largest single file that may be imported, in megabytes.
WEBDAV_MAX_IMPORT_MB=512

UNSPLASH_ACCESS_KEY=
UNSPLASH_SECRET_KEY=
GIPHY_API_KEY=
Expand Down
3 changes: 2 additions & 1 deletion app/Enums/Media/Source.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
* Origin of a media attachment on a post. `null`/absent means "uploaded by
* the user" (legacy/unknown). The enum is forward-compatible so the regen
* UI can decide on a per-source basis what actions are available (only `Ai`
* exposes a "regenerate" button today, but `Unsplash`/`Giphy` may surface
* exposes a "regenerate" button today, but `Unsplash`/`Giphy`/`Webdav` may surface
* "fetch alternate" or attribution UIs later).
*/
enum Source: string
{
case Ai = 'ai';
case Unsplash = 'unsplash';
case Giphy = 'giphy';
case Webdav = 'webdav';
}
90 changes: 90 additions & 0 deletions app/Http/Controllers/App/WebdavController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\App;

use App\Http\Requests\App\Asset\ImportWebdavRequest;
use App\Http\Resources\App\MediaResource;
use App\Services\WebdavService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class WebdavController extends Controller
{
public function browse(Request $request, WebdavService $webdav): JsonResponse
{
$workspace = $request->user()->currentWorkspace;

$this->authorize('createPost', $workspace);

abort_unless($webdav->enabled(), SymfonyResponse::HTTP_NOT_FOUND);

try {
return response()->json($webdav->list((string) $request->string('path')));
} catch (RuntimeException $e) {
abort(SymfonyResponse::HTTP_BAD_GATEWAY, $e->getMessage());
}
}

public function store(ImportWebdavRequest $request, WebdavService $webdav): JsonResponse
{
$workspace = $request->user()->currentWorkspace;

$this->authorize('createPost', $workspace);

abort_unless($webdav->enabled(), SymfonyResponse::HTTP_NOT_FOUND);

$imported = [];
$failed = [];

foreach ($request->validated('paths') as $path) {
$temporary = null;

try {
$temporary = $webdav->download($path);

$imported[] = $workspace->addMediaFromPath(
filePath: $temporary,
originalFilename: basename($webdav->normalize($path)),
collection: 'assets',
);
} catch (InvalidArgumentException|RuntimeException $e) {
// A share holds everything, not just postable media, and one
// unreadable file should not discard the rest of a selection.
$failed[] = [
'path' => $path,
'name' => basename($webdav->normalize($path)),
'message' => $e->getMessage(),
'status' => $e instanceof InvalidArgumentException
? SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY
: SymfonyResponse::HTTP_BAD_GATEWAY,
];
} finally {
if ($temporary !== null && file_exists($temporary)) {
@unlink($temporary);
}
}
}

// Nothing came through: report it as the error it is, using the reason
// of the first file so the message stays specific.
if ($imported === [] && $failed !== []) {
abort($failed[0]['status'], $failed[0]['message']);
}

return response()->json([
'media' => MediaResource::collection($imported),
'failed' => array_map(
static fn (array $failure): array => [
'name' => $failure['name'],
'message' => $failure['message'],
],
$failed,
),
]);
}
}
3 changes: 3 additions & 0 deletions app/Http/Middleware/App/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use App\Http\Resources\App\PlanResource;
use App\Models\Plan;
use App\Services\WebdavService;
use Illuminate\Http\Request;
use Inertia\Middleware;

Expand Down Expand Up @@ -63,6 +64,8 @@ public function share(Request $request): array
'locale' => app()->getLocale(),
'languages' => Locale::options(),
'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')),
'webdavEnabled' => app(WebdavService::class)->enabled(),
'webdavLabel' => (string) config('services.webdav.label'),
'selfHosted' => $isSelfHosted,
'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(),
'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(),
Expand Down
21 changes: 21 additions & 0 deletions app/Http/Requests/App/Asset/ImportWebdavRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\App\Asset;

use Illuminate\Foundation\Http\FormRequest;

class ImportWebdavRequest extends FormRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'paths' => ['required', 'array', 'min:1', 'max:20'],
'paths.*' => ['required', 'string', 'max:1024'],
];
}
}
Loading