feat(plus): add SFTP support

This commit is contained in:
Phan An 2024-04-26 15:35:26 +02:00
parent 448cbed731
commit aa787edb2e
41 changed files with 552 additions and 160 deletions

View file

@ -9,14 +9,15 @@ enum SongStorageType: string
case S3 = 's3';
case S3_LAMBDA = 's3-lambda';
case DROPBOX = 'dropbox';
case SFTP = 'sftp';
case LOCAL = '';
public function supported(): bool
{
if ($this === self::LOCAL || $this === self::S3_LAMBDA) {
if (License::isPlus()) {
return true;
}
return License::isPlus();
return $this === self::LOCAL || $this === self::S3_LAMBDA;
}
}

View file

@ -19,6 +19,7 @@ class RegisterPlayController extends Controller
InteractionService $interactionService,
?Authenticatable $user
) {
/** @var Song $song */
$song = Song::query()->findOrFail($request->song);
$this->authorize('access', $song);

View file

@ -5,11 +5,12 @@ namespace App\Models;
use App\Builders\SongBuilder;
use App\Enums\SongStorageType;
use App\Models\Concerns\SupportsDeleteWhereValueNotIn;
use App\Values\SongStorageMetadata\Contracts\SongStorageMetadata;
use App\Values\SongStorageMetadata\DropboxMetadata;
use App\Values\SongStorageMetadata\LegacyS3Metadata;
use App\Values\SongStorageMetadata\LocalMetadata;
use App\Values\SongStorageMetadata\S3CompatibleMetadata;
use App\Values\SongStorageMetadata\S3LambdaMetadata;
use App\Values\SongStorageMetadata\SftpMetadata;
use App\Values\SongStorageMetadata\SongStorageMetadata;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -170,13 +171,17 @@ class Song extends Model
get: function (): SongStorageMetadata {
try {
switch ($this->storage) {
case SongStorageType::SFTP:
preg_match('/^sftp:\\/\\/(.*)/', $this->path, $matches);
return SftpMetadata::make($matches[1]);
case SongStorageType::S3:
preg_match('/^s3:\\/\\/(.*)\\/(.*)/', $this->path, $matches);
return S3CompatibleMetadata::make($matches[1], $matches[2]);
case SongStorageType::S3_LAMBDA:
preg_match('/^s3:\\/\\/(.*)\\/(.*)/', $this->path, $matches);
return LegacyS3Metadata::make($matches[1], $matches[2]);
return S3LambdaMetadata::make($matches[1], $matches[2]);
case SongStorageType::DROPBOX:
preg_match('/^dropbox:\\/\\/(.*)/', $this->path, $matches);

View file

@ -5,6 +5,7 @@ namespace App\Providers;
use App\Services\SongStorages\DropboxStorage;
use App\Services\SongStorages\LocalStorage;
use App\Services\SongStorages\S3CompatibleStorage;
use App\Services\SongStorages\SftpStorage;
use App\Services\SongStorages\SongStorage;
use Illuminate\Support\ServiceProvider;
@ -16,6 +17,7 @@ class SongStorageServiceProvider extends ServiceProvider
$concrete = match (config('koel.storage_driver')) {
's3' => S3CompatibleStorage::class,
'dropbox' => DropboxStorage::class,
'sftp' => SftpStorage::class,
default => LocalStorage::class,
};

View file

@ -7,6 +7,7 @@ use App\Models\Song;
use App\Models\SongZipArchive;
use App\Services\SongStorages\DropboxStorage;
use App\Services\SongStorages\S3CompatibleStorage;
use App\Services\SongStorages\SftpStorage;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
@ -34,6 +35,10 @@ class DownloadService
return File::exists($song->path) ? $song->path : null;
}
if ($song->storage === SongStorageType::SFTP) {
return app(SftpStorage::class)->copyToLocal($song);
}
switch ($song->storage) {
case SongStorageType::DROPBOX:
$cloudStorage = app(DropboxStorage::class);

View file

@ -2,45 +2,22 @@
namespace App\Services\SongStorages;
use App\Exceptions\SongUploadFailedException;
use App\Models\Song;
use App\Models\User;
use App\Services\FileScanner;
use App\Values\ScanConfiguration;
use App\Values\ScanResult;
use Illuminate\Http\UploadedFile;
use App\Services\SongStorages\Concerns\ScansUploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Uid\Ulid;
abstract class CloudStorage extends SongStorage
{
use ScansUploadedFile;
public function __construct(protected FileScanner $scanner)
{
}
protected function scanUploadedFile(UploadedFile $file, User $uploader): ScanResult
{
self::assertSupported();
// Can't scan the uploaded file directly, as it apparently causes some misbehavior during idv3 tag reading.
// Instead, we copy the file to the tmp directory and scan it from there.
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
File::ensureDirectoryExists($tmpDir);
$tmpFile = $file->move($tmpDir, $file->getClientOriginalName());
$result = $this->scanner->setFile($tmpFile)
->scan(ScanConfiguration::make(
owner: $uploader,
makePublic: $uploader->preferences->makeUploadsPublic
));
throw_if($result->isError(), new SongUploadFailedException($result->error));
return $result;
}
public function copyToLocal(Song $song): string
{
self::assertSupported();

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\SongStorages\Concerns;
use App\Models\Song;
use Illuminate\Contracts\Filesystem\Filesystem as IlluminateFilesystem;
use League\Flysystem\Filesystem;
trait DeletesUsingFilesystem
{
private function deleteUsingFileSystem(Filesystem | IlluminateFilesystem $disk, Song $song, bool $backup): void
{
$path = $song->storage_metadata->getPath();
if ($backup) {
$disk->move($path, "backup/$path");
}
$disk->delete($path);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Services\SongStorages\Concerns;
use App\Exceptions\SongUploadFailedException;
use App\Models\User;
use App\Services\FileScanner;
use App\Values\ScanConfiguration;
use App\Values\ScanResult;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
trait ScansUploadedFile
{
protected function scanUploadedFile(FileScanner $scanner, UploadedFile $file, User $uploader): ScanResult
{
// Can't scan the uploaded file directly, as it apparently causes some misbehavior during idv3 tag reading.
// Instead, we copy the file to the tmp directory and scan it from there.
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
File::ensureDirectoryExists($tmpDir);
$tmpFile = $file->move($tmpDir, $file->getClientOriginalName());
$result = $scanner->setFile($tmpFile)
->scan(ScanConfiguration::make(
owner: $uploader,
makePublic: $uploader->preferences->makeUploadsPublic
));
throw_if($result->isError(), new SongUploadFailedException($result->error));
return $result;
}
}

View file

@ -7,6 +7,7 @@ use App\Filesystems\DropboxFilesystem;
use App\Models\Song;
use App\Models\User;
use App\Services\FileScanner;
use App\Services\SongStorages\Concerns\DeletesUsingFilesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@ -15,6 +16,8 @@ use Illuminate\Support\Facades\Http;
final class DropboxStorage extends CloudStorage
{
use DeletesUsingFilesystem;
public function __construct(
protected FileScanner $scanner,
private readonly DropboxFilesystem $filesystem,
@ -30,7 +33,7 @@ final class DropboxStorage extends CloudStorage
self::assertSupported();
return DB::transaction(function () use ($file, $uploader): Song {
$result = $this->scanUploadedFile($file, $uploader);
$result = $this->scanUploadedFile($this->scanner, $file, $uploader);
$song = $this->scanner->getSong();
$key = $this->generateStorageKey($file->getClientOriginalName(), $uploader);
@ -78,22 +81,10 @@ final class DropboxStorage extends CloudStorage
return $this->filesystem->temporaryUrl($song->storage_metadata->getPath());
}
public function supported(): bool
{
return SongStorageType::DROPBOX->supported();
}
public function delete(Song $song, bool $backup = false): void
{
self::assertSupported();
$path = $song->storage_metadata->getPath();
if ($backup) {
$this->filesystem->move($path, "backup/$path");
}
$this->filesystem->delete($path);
$this->deleteUsingFileSystem($this->filesystem, $song, $backup);
}
public function testSetup(): void
@ -101,4 +92,9 @@ final class DropboxStorage extends CloudStorage
$this->filesystem->write('test.txt', 'Koel test file.');
$this->filesystem->delete('test.txt');
}
protected function getStorageType(): SongStorageType
{
return SongStorageType::DROPBOX;
}
}

View file

@ -19,7 +19,7 @@ use function Functional\memoize;
final class LocalStorage extends SongStorage
{
public function __construct(private FileScanner $scanner)
public function __construct(private readonly FileScanner $scanner)
{
}
@ -90,11 +90,6 @@ final class LocalStorage extends SongStorage
return substr(sha1(uniqid()), 0, 6);
}
public function supported(): bool
{
return SongStorageType::LOCAL->supported();
}
public function delete(Song $song, bool $backup = false): void
{
$path = $song->storage_metadata->getPath();
@ -105,4 +100,9 @@ final class LocalStorage extends SongStorage
throw_unless(File::delete($path), new Exception("Failed to delete song file: $path"));
}
protected function getStorageType(): SongStorageType
{
return SongStorageType::LOCAL;
}
}

View file

@ -6,6 +6,7 @@ use App\Enums\SongStorageType;
use App\Models\Song;
use App\Models\User;
use App\Services\FileScanner;
use App\Services\SongStorages\Concerns\DeletesUsingFilesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
@ -13,6 +14,8 @@ use Illuminate\Support\Facades\Storage;
class S3CompatibleStorage extends CloudStorage
{
use DeletesUsingFilesystem;
public function __construct(protected FileScanner $scanner, private readonly string $bucket)
{
parent::__construct($scanner);
@ -23,7 +26,7 @@ class S3CompatibleStorage extends CloudStorage
self::assertSupported();
return DB::transaction(function () use ($file, $uploader): Song {
$result = $this->scanUploadedFile($file, $uploader);
$result = $this->scanUploadedFile($this->scanner, $file, $uploader);
$song = $this->scanner->getSong();
$key = $this->generateStorageKey($file->getClientOriginalName(), $uploader);
@ -50,15 +53,7 @@ class S3CompatibleStorage extends CloudStorage
public function delete(Song $song, bool $backup = false): void
{
self::assertSupported();
$disk = Storage::disk('s3');
$path = $song->storage_metadata->getPath();
if ($backup) {
$disk->move($path, "backup/$path");
}
$disk->delete($song->storage_metadata->getPath());
$this->deleteUsingFileSystem(Storage::disk('s3'), $song, $backup);
}
public function testSetup(): void
@ -67,8 +62,8 @@ class S3CompatibleStorage extends CloudStorage
Storage::disk('s3')->delete('test.txt');
}
public function supported(): bool
protected function getStorageType(): SongStorageType
{
return SongStorageType::S3->supported();
return SongStorageType::S3;
}
}

View file

@ -84,13 +84,13 @@ final class S3LambdaStorage extends S3CompatibleStorage
$song->delete();
}
public function supported(): bool
{
return SongStorageType::S3_LAMBDA->supported();
}
public function delete(Song $song, bool $backup = false): void
{
throw new MethodNotImplementedException('Lambda storage does not support deleting from filesystem.');
}
protected function getStorageType(): SongStorageType
{
return SongStorageType::S3_LAMBDA;
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Services\SongStorages;
use App\Enums\SongStorageType;
use App\Models\Song;
use App\Models\User;
use App\Services\FileScanner;
use App\Services\SongStorages\Concerns\DeletesUsingFilesystem;
use App\Services\SongStorages\Concerns\ScansUploadedFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Uid\Ulid;
final class SftpStorage extends SongStorage
{
use DeletesUsingFilesystem;
use ScansUploadedFile;
public function __construct(protected FileScanner $scanner)
{
}
public function storeUploadedFile(UploadedFile $file, User $uploader): Song
{
self::assertSupported();
return DB::transaction(function () use ($file, $uploader): Song {
$result = $this->scanUploadedFile($this->scanner, $file, $uploader);
$song = $this->scanner->getSong();
$path = $this->generateRemotePath($file->getClientOriginalName(), $uploader);
Storage::disk('sftp')->put($path, File::get($result->path));
$song->update([
'path' => "sftp://$path",
'storage' => SongStorageType::SFTP,
]);
File::delete($result->path);
return $song;
});
}
public function delete(Song $song, bool $backup = false): void
{
self::assertSupported();
$this->deleteUsingFileSystem(Storage::disk('sftp'), $song, $backup);
}
public function getSongContent(Song $song): string
{
self::assertSupported();
return Storage::disk('sftp')->get($song->storage_metadata->getPath());
}
public function copyToLocal(Song $song): string
{
self::assertSupported();
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
File::ensureDirectoryExists($tmpDir);
$localPath = $tmpDir . DIRECTORY_SEPARATOR . basename($song->storage_metadata->getPath());
File::put($localPath, $this->getSongContent($song));
return $localPath;
}
private function generateRemotePath(string $filename, User $uploader): string
{
return sprintf('%s__%s__%s', $uploader->id, Str::lower(Ulid::generate()), $filename);
}
protected function getStorageType(): SongStorageType
{
return SongStorageType::SFTP;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services\SongStorages;
use App\Enums\SongStorageType;
use App\Exceptions\KoelPlusRequiredException;
use App\Models\Song;
use App\Models\User;
@ -9,16 +10,16 @@ use Illuminate\Http\UploadedFile;
abstract class SongStorage
{
abstract protected function getStorageType(): SongStorageType;
abstract public function storeUploadedFile(UploadedFile $file, User $uploader): Song;
abstract public function delete(Song $song, bool $backup = false): void;
abstract protected function supported(): bool;
protected function assertSupported(): void
{
throw_unless(
$this->supported(),
$this->getStorageType()->supported(),
new KoelPlusRequiredException('The storage driver is only supported in Koel Plus.')
);
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Services\Streamer\Adapters\Concerns;
use DaveRandom\Resume\FileResource;
use DaveRandom\Resume\InvalidRangeHeaderException;
use DaveRandom\Resume\NonExistentFileException;
use DaveRandom\Resume\RangeSet;
use DaveRandom\Resume\ResourceServlet;
use DaveRandom\Resume\SendFileFailureException;
use DaveRandom\Resume\UnreadableFileException;
use DaveRandom\Resume\UnsatisfiableRangeException;
use Symfony\Component\HttpFoundation\Response;
use function DaveRandom\Resume\get_request_header;
trait StreamsLocalPath
{
private function streamLocalPath(string $path): string
{
try {
$rangeHeader = get_request_header('Range');
// On Safari, "Range" header value can be "bytes=0-1" which breaks streaming.
$rangeHeader = $rangeHeader === 'bytes=0-1' ? 'bytes=0-' : $rangeHeader;
$rangeSet = RangeSet::createFromHeader($rangeHeader);
$resource = new FileResource($path, mime_content_type($path));
(new ResourceServlet($resource))->sendResource($rangeSet);
} catch (InvalidRangeHeaderException) {
abort(Response::HTTP_BAD_REQUEST);
} catch (UnsatisfiableRangeException) {
abort(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
} catch (NonExistentFileException) {
abort(Response::HTTP_NOT_FOUND);
} catch (UnreadableFileException) {
abort(Response::HTTP_INTERNAL_SERVER_ERROR);
} catch (SendFileFailureException $e) {
abort_unless(headers_sent(), Response::HTTP_INTERNAL_SERVER_ERROR);
echo "An error occurred while attempting to send the requested resource: {$e->getMessage()}";
}
exit;
}
}

View file

@ -3,45 +3,14 @@
namespace App\Services\Streamer\Adapters;
use App\Models\Song;
use DaveRandom\Resume\FileResource;
use DaveRandom\Resume\InvalidRangeHeaderException;
use DaveRandom\Resume\NonExistentFileException;
use DaveRandom\Resume\RangeSet;
use DaveRandom\Resume\ResourceServlet;
use DaveRandom\Resume\SendFileFailureException;
use DaveRandom\Resume\UnreadableFileException;
use DaveRandom\Resume\UnsatisfiableRangeException;
use Symfony\Component\HttpFoundation\Response;
use function DaveRandom\Resume\get_request_header;
use App\Services\Streamer\Adapters\Concerns\StreamsLocalPath;
class PhpStreamerAdapter extends LocalStreamerAdapter
{
use StreamsLocalPath;
public function stream(Song $song, array $config = []): void
{
try {
$path = $song->storage_metadata->getPath();
$rangeHeader = get_request_header('Range');
// On Safari, "Range" header value can be "bytes=0-1" which breaks streaming.
$rangeHeader = $rangeHeader === 'bytes=0-1' ? 'bytes=0-' : $rangeHeader;
$rangeSet = RangeSet::createFromHeader($rangeHeader);
$resource = new FileResource($path, mime_content_type($path));
(new ResourceServlet($resource))->sendResource($rangeSet);
} catch (InvalidRangeHeaderException) {
abort(Response::HTTP_BAD_REQUEST);
} catch (UnsatisfiableRangeException) {
abort(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
} catch (NonExistentFileException) {
abort(Response::HTTP_NOT_FOUND);
} catch (UnreadableFileException) {
abort(Response::HTTP_INTERNAL_SERVER_ERROR);
} catch (SendFileFailureException $e) {
abort_unless(headers_sent(), Response::HTTP_INTERNAL_SERVER_ERROR);
echo "An error occurred while attempting to send the requested resource: {$e->getMessage()}";
}
exit;
$this->streamLocalPath($song->storage_metadata->getPath());
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\Streamer\Adapters;
use App\Models\Song;
use App\Services\SongStorages\SftpStorage;
use App\Services\Streamer\Adapters\Concerns\StreamsLocalPath;
class SftpStreamerAdapter implements StreamerAdapter
{
use StreamsLocalPath;
public function __construct(private readonly SftpStorage $storage)
{
}
public function stream(Song $song, array $config = []): void
{
$this->streamLocalPath($this->storage->copyToLocal($song));
}
}

View file

@ -8,6 +8,7 @@ use App\Models\Song;
use App\Services\Streamer\Adapters\DropboxStreamerAdapter;
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamer\Adapters\S3CompatibleStreamerAdapter;
use App\Services\Streamer\Adapters\SftpStreamerAdapter;
use App\Services\Streamer\Adapters\StreamerAdapter;
use App\Services\Streamer\Adapters\TranscodingStreamerAdapter;
use Illuminate\Support\Arr;
@ -16,17 +17,15 @@ use Illuminate\Support\Str;
class Streamer
{
private StreamerAdapter $adapter;
public function __construct(
private readonly Song $song,
?StreamerAdapter $adapter = null,
private ?StreamerAdapter $adapter = null,
private readonly array $config = []
) {
// Turn off error reporting to make sure our stream isn't interfered.
@error_reporting(0);
$this->adapter = $adapter ?? $this->resolveAdapter();
$this->adapter ??= $this->resolveAdapter();
}
private function resolveAdapter(): StreamerAdapter
@ -39,6 +38,7 @@ class Streamer
return match ($this->song->storage) {
SongStorageType::LOCAL => app(LocalStreamerAdapter::class),
SongStorageType::SFTP => app(SftpStreamerAdapter::class),
SongStorageType::S3, SongStorageType::S3_LAMBDA => app(S3CompatibleStreamerAdapter::class),
SongStorageType::DROPBOX => app(DropboxStreamerAdapter::class),
};

View file

@ -1,8 +0,0 @@
<?php
namespace App\Values\SongStorageMetadata\Contracts;
interface SongStorageMetadata
{
public function getPath(): string;
}

View file

@ -2,9 +2,7 @@
namespace App\Values\SongStorageMetadata;
use App\Values\SongStorageMetadata\Contracts\SongStorageMetadata;
class DropboxMetadata implements SongStorageMetadata
final class DropboxMetadata extends SongStorageMetadata
{
private function __construct(public string $path)
{
@ -12,7 +10,7 @@ class DropboxMetadata implements SongStorageMetadata
public static function make(string $key): self
{
return new static($key);
return new self($key);
}
public function getPath(): string

View file

@ -1,7 +0,0 @@
<?php
namespace App\Values\SongStorageMetadata;
final class LegacyS3Metadata extends S3CompatibleMetadata
{
}

View file

@ -2,9 +2,7 @@
namespace App\Values\SongStorageMetadata;
use App\Values\SongStorageMetadata\Contracts\SongStorageMetadata;
final class LocalMetadata implements SongStorageMetadata
final class LocalMetadata extends SongStorageMetadata
{
private function __construct(public string $path)
{

View file

@ -2,9 +2,7 @@
namespace App\Values\SongStorageMetadata;
use App\Values\SongStorageMetadata\Contracts\SongStorageMetadata;
class S3CompatibleMetadata implements SongStorageMetadata
class S3CompatibleMetadata extends SongStorageMetadata
{
private function __construct(public string $bucket, public string $key)
{

View file

@ -0,0 +1,7 @@
<?php
namespace App\Values\SongStorageMetadata;
final class S3LambdaMetadata extends S3CompatibleMetadata
{
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Values\SongStorageMetadata;
final class SftpMetadata extends SongStorageMetadata
{
private function __construct(public string $path)
{
}
public static function make(string $key): self
{
return new self($key);
}
public function getPath(): string
{
return $this->path;
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Values\SongStorageMetadata;
abstract class SongStorageMetadata
{
abstract public function getPath(): string;
}

View file

@ -40,7 +40,8 @@
"saloonphp/laravel-plugin": "^3.0",
"laravel/socialite": "^5.12",
"laravel/ui": "^4.5",
"nunomaduro/collision": "^7.10"
"nunomaduro/collision": "^7.10",
"league/flysystem-sftp-v3": "^3.0"
},
"require-dev": {
"mockery/mockery": "~1.0",

61
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ca3c6672b523e87b03823ba9476fbd5a",
"content-hash": "964433231b2e294b86a53fae4e5297c0",
"packages": [
{
"name": "algolia/algoliasearch-client-php",
@ -3357,6 +3357,65 @@
],
"time": "2024-03-15T19:58:44+00:00"
},
{
"name": "league/flysystem-sftp-v3",
"version": "3.26.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-sftp-v3.git",
"reference": "bb186407f8b6e71df2caa35436c3426466d84048"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/bb186407f8b6e71df2caa35436c3426466d84048",
"reference": "bb186407f8b6e71df2caa35436c3426466d84048",
"shasum": ""
},
"require": {
"league/flysystem": "^3.0.14",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2",
"phpseclib/phpseclib": "^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\PhpseclibV3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "SFTP filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"file",
"files",
"filesystem",
"sftp"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.26.0"
},
"funding": [
{
"url": "https://ecologi.com/frankdejonge",
"type": "custom"
},
{
"url": "https://github.com/frankdejonge",
"type": "github"
}
],
"time": "2024-03-24T12:11:42+00:00"
},
{
"name": "league/mime-type-detection",
"version": "1.15.0",

View file

@ -44,6 +44,7 @@ return [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => true,
],
'ftp' => [
@ -58,6 +59,7 @@ return [
// 'passive' => true,
// 'ssl' => true,
// 'timeout' => 30,
'throw' => true,
],
's3' => [
@ -78,6 +80,19 @@ return [
'refresh_token' => env('DROPBOX_REFRESH_TOKEN'),
],
'sftp' => [
'driver' => 'sftp',
'host' => env('SFTP_HOST'),
'root' => rtrim(env('SFTP_ROOT'), '/\\'),
'username' => env('SFTP_USERNAME'),
'password' => env('SFTP_PASSWORD'),
'privateKey' => env('SFTP_PRIVATE_KEY'),
'passphrase' => env('SFTP_PASSPHRASE'),
'throw' => true,
],
'rackspace' => [
'driver' => 'rackspace',
'username' => 'your-username',

View file

@ -21,7 +21,7 @@
}
img, video, figure {
border-radius: .375rem; // md
border-radius: .375rem;
display: block;
outline: 1px solid rgba(99, 102, 241, .25);
}

View file

@ -7,7 +7,7 @@ import MobileAppScreenshots from '../components/MobileAppScreenshots.vue'
import PlusBadge from '../components/PlusBadge.vue'
import CaptionedImage from '../components/CaptionedImage.vue'
import Layout from '../layout/Layout.vue'
import './custom.scss'
import './custom.pcss'
export default {
Layout,

View file

@ -1,7 +1,7 @@
# Cloud Storage Support
In addition to storing your music on the same server as Koels installation via the `local` storage driver,
Koel Plus offers several different file storage options, including Amazon S3, S3-compatible services, Dropbox, and likely more in the future.
In addition to storing your music on the same server as Koels installation via the `local` storage drivers,
Koel Plus offers several different file storage options, including SFTP, Amazon S3, S3-compatible services, Dropbox, and likely more in the future.
This page will guide you through the process of setting up these storage options.
:::warning Warning
@ -13,6 +13,32 @@ The screenshots and instructions on this page may not be 100% up-to-date as 3rd-
The general idea, however, should remain the same.
:::
## SFTP
To use SFTP as your storage driver, you need to have an SFTP server set up and running. Many cloud hosting providers offer SFTP access to their storage services.
To enable SFTP storage support in Koel, you need to provide the following configuration in your `.env` file:
```
STORAGE_DRIVER=sftp
SFTP_HOST=
SFTP_PORT=
# The absolute path of the directory to store the media files on the SFTP server.
# Make sure the directory exists and is writable by the SFTP user.
SFTP_ROOT=
# You can use either a username/password pair…
SFTP_USERNAME=
SFTP_PASSWORD=
# …or private key authentication:
SFTP_PRIVATE_KEY=
SFTP_PASSPHRASE=
```
After reloading, Koel will start using SFTP as its storage driver. You can now upload your music files to your SFTP server directly from Koels web interface.
## Amazon S3 and Compatible Services
Since Amazon S3 and S3-compatible services share the same API, you can use the same configuration (`AWS_*`) for both.

View file

@ -7,7 +7,7 @@ Koel Plus is the premium version of Koel. It offers additional features and enha
- **Multi-library Support**: In Koel Plus, each user has their own library, which they populate by uploading their own music.
- **Music Sharing**: By default, uploaded music is private to the user who uploaded it. However, users can choose to share their music with others by marking songs as public.
- **[Collaboration](./collaboration)**: Users can invite others to collaborate on their playlists, allowing them to add, remove, and reorder songs.
- **[Cloud Storage Support](./cloud-storage-support)**: In addition to local storage, Koel Plus supports cloud storage services like Amazon S3 (or any S3-compatible service) and Dropbox.
- **[Cloud Storage Support](./cloud-storage-support)**: In addition to local storage, Koel Plus supports remote and cloud storage drivers like SFTP, Amazon S3 (or any S3-compatible service), and Dropbox.
- **[Single Sign-On (SSO) Support](./sso)**: Users can log into Koel Plus using their existing credentials from another service like Google.
Other features are being planned or actively developed, for example:

View file

@ -0,0 +1,18 @@
<?php
namespace Tests\Integration\Enums;
use App\Enums\SongStorageType;
use Tests\TestCase;
class SongStorageTypeTest extends TestCase
{
public function testSupported(): void
{
self::assertTrue(SongStorageType::LOCAL->supported());
self::assertTrue(SongStorageType::S3_LAMBDA->supported());
self::assertFalse(SongStorageType::SFTP->supported());
self::assertFalse(SongStorageType::DROPBOX->supported());
self::assertFalse(SongStorageType::S3->supported());
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Tests\Integration\KoelPlus\Enums;
use App\Enums\SongStorageType;
use Tests\PlusTestCase;
class SongStorageTypeTest extends PlusTestCase
{
public function testSupported(): void
{
self::assertTrue(collect(SongStorageType::cases())->every(
static fn (SongStorageType $type) => $type->supported()
));
}
}

View file

@ -49,13 +49,6 @@ class DropboxStorageTest extends PlusTestCase
$this->file = UploadedFile::fromFile(test_path('songs/full.mp3'), 'song.mp3'); //@phpstan-ignore-line
}
public function testSupported(): void
{
$this->client->allows('setAccessToken');
self::assertTrue(app(DropboxStorage::class)->supported());
}
public function testStoreUploadedFile(): void
{
$this->client->shouldReceive('setAccessToken')->with('free-bird')->once();

View file

@ -24,11 +24,6 @@ class S3CompatibleStorageTest extends PlusTestCase
$this->file = UploadedFile::fromFile(test_path('songs/full.mp3'), 'song.mp3'); //@phpstan-ignore-line
}
public function testSupported(): void
{
self::assertTrue($this->service->supported());
}
public function testStoreUploadedFile(): void
{
self::assertEquals(0, Song::query()->where('storage', 's3')->count());

View file

@ -0,0 +1,88 @@
<?php
namespace Tests\Integration\KoelPlus\Services\SongStorages;
use App\Models\Song;
use App\Services\SongStorages\SftpStorage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\PlusTestCase;
use function Tests\create_user;
use function Tests\test_path;
class SftpStorageTest extends PlusTestCase
{
private SftpStorage $service;
private UploadedFile $file;
public function setUp(): void
{
parent::setUp();
$this->service = app(SftpStorage::class);
$this->file = UploadedFile::fromFile(test_path('songs/full.mp3'), 'song.mp3'); //@phpstan-ignore-line
}
public function testStoreUploadedFile(): void
{
self::assertEquals(0, Song::query()->where('storage', 'sftp')->count());
Storage::fake('sftp');
$song = $this->service->storeUploadedFile($this->file, create_user());
Storage::disk('sftp')->assertExists($song->storage_metadata->getPath());
self::assertEquals(1, Song::query()->where('storage', 'sftp')->count());
}
public function testStoringWithVisibilityPreference(): void
{
$user = create_user();
$user->preferences->makeUploadsPublic = true;
$user->save();
self::assertTrue($this->service->storeUploadedFile($this->file, $user)->is_public);
$user->preferences->makeUploadsPublic = false;
$user->save();
$privateFile = UploadedFile::fromFile(test_path('songs/full.mp3'), 'song.mp3'); //@phpstan-ignore-line
self::assertFalse($this->service->storeUploadedFile($privateFile, $user)->is_public);
}
public function testDelete(): void
{
Storage::fake('sftp');
$song = $this->service->storeUploadedFile($this->file, create_user());
Storage::disk('sftp')->assertExists($song->storage_metadata->getPath());
$this->service->delete($song);
Storage::disk('sftp')->assertMissing($song->storage_metadata->getPath());
}
public function testGetSongContent(): void
{
/** @var Song $song */
$song = Song::factory()->create();
Storage::fake('sftp');
Storage::shouldReceive('disk->get')->with($song->storage_metadata->getPath())->andReturn('binary-content');
self::assertEquals('binary-content', $this->service->getSongContent($song));
}
public function testCopyToLocal(): void
{
/** @var Song $song */
$song = Song::factory()->create();
Storage::fake('sftp');
Storage::shouldReceive('disk->get')->with($song->storage_metadata->getPath())->andReturn('binary-content');
$localPath = $this->service->copyToLocal($song);
self::assertStringEqualsFile($localPath, 'binary-content');
}
}

View file

@ -7,7 +7,9 @@ use App\Models\Song;
use App\Services\Streamer\Adapters\DropboxStreamerAdapter;
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamer\Adapters\S3CompatibleStreamerAdapter;
use App\Services\Streamer\Adapters\SftpStreamerAdapter;
use App\Services\Streamer\Streamer;
use Exception;
use Illuminate\Support\Facades\File;
use Tests\PlusTestCase;
@ -20,7 +22,7 @@ class StreamerTest extends PlusTestCase
collect(SongStorageType::cases())
->each(static function (SongStorageType $type): void {
/** @var Song $song */
$song = Song::factory()->make(['storage' => $type]);
$song = Song::factory()->create(['storage' => $type]);
$streamer = new Streamer($song);
switch ($type) {
@ -36,6 +38,13 @@ class StreamerTest extends PlusTestCase
case SongStorageType::LOCAL:
self::assertInstanceOf(LocalStreamerAdapter::class, $streamer->getAdapter());
break;
case SongStorageType::SFTP:
self::assertInstanceOf(SftpStreamerAdapter::class, $streamer->getAdapter());
break;
default:
throw new Exception('Storage type not covered by tests: ' . $type->value);
}
});
}

View file

@ -12,6 +12,7 @@ use App\Services\Streamer\Adapters\TranscodingStreamerAdapter;
use App\Services\Streamer\Adapters\XAccelRedirectStreamerAdapter;
use App\Services\Streamer\Adapters\XSendFileStreamerAdapter;
use App\Services\Streamer\Streamer;
use Exception;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
@ -40,6 +41,9 @@ class StreamerTest extends TestCase
case SongStorageType::LOCAL:
self::assertInstanceOf(LocalStreamerAdapter::class, (new Streamer($song))->getAdapter());
break;
default:
throw new Exception('Storage type uncovered by tests.');
}
});
}

View file

@ -123,9 +123,4 @@ class S3LambdaStorageTest extends TestCase
self::assertModelMissing($song);
}
public function testSupported(): void
{
self::assertTrue($this->storage->supported());
}
}