Skip to content
Open
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,19 @@ NIGHTWATCH_TOKEN=
# GOOGLE_BUSINESS_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