2023-09-22 19:31:47 +00:00
< ? php
namespace App\Models ;
2024-08-05 18:00:57 +00:00
use App\Events\FileStorageChanged ;
2025-03-14 13:55:38 +00:00
use Illuminate\Database\Eloquent\Casts\Attribute ;
2023-09-22 19:31:47 +00:00
use Illuminate\Database\Eloquent\Factories\HasFactory ;
2025-10-03 14:39:57 +00:00
use Symfony\Component\Yaml\Yaml ;
2023-09-22 19:31:47 +00:00
class LocalFileVolume extends BaseModel
{
2025-01-30 13:16:27 +00:00
protected $casts = [
2025-03-29 21:16:12 +00:00
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
2025-01-30 13:16:27 +00:00
'content' => 'encrypted' ,
2025-03-25 08:40:36 +00:00
'is_directory' => 'boolean' ,
2025-01-30 13:16:27 +00:00
];
2023-09-22 19:31:47 +00:00
use HasFactory ;
2024-06-10 20:43:34 +00:00
2023-09-22 19:31:47 +00:00
protected $guarded = [];
2025-03-14 13:55:38 +00:00
public $appends = [ 'is_binary' ];
2024-02-14 09:21:53 +00:00
protected static function booted ()
{
2025-01-07 14:31:43 +00:00
static :: created ( function ( LocalFileVolume $fileVolume ) {
$fileVolume -> load ([ 'service' ]);
dispatch ( new \App\Jobs\ServerStorageSaveJob ( $fileVolume ));
2024-02-14 09:21:53 +00:00
});
}
2024-06-10 20:43:34 +00:00
2025-03-14 13:55:38 +00:00
protected function isBinary () : Attribute
{
return Attribute :: make (
get : function () {
return $this -> content === '[binary file]' ;
}
);
}
2023-09-22 19:31:47 +00:00
public function service ()
{
2023-09-25 10:49:55 +00:00
return $this -> morphTo ( 'resource' );
2023-09-22 19:31:47 +00:00
}
2024-06-10 20:43:34 +00:00
2024-08-12 14:06:24 +00:00
public function loadStorageOnServer ()
{
$this -> load ([ 'service' ]);
$isService = data_get ( $this -> resource , 'service' );
if ( $isService ) {
$workdir = $this -> resource -> service -> workdir ();
$server = $this -> resource -> service -> server ;
} else {
$workdir = $this -> resource -> workdir ();
$server = $this -> resource -> destination -> server ;
}
2025-01-07 14:31:43 +00:00
$commands = collect ([]);
2024-08-12 14:06:24 +00:00
$path = data_get_str ( $this , 'fs_path' );
if ( $path -> startsWith ( '.' )) {
$path = $path -> after ( '.' );
$path = $workdir . $path ;
}
2025-11-27 13:36:31 +00:00
// Validate and escape path to prevent command injection
validateShellSafePath ( $path , 'storage path' );
$escapedPath = escapeshellarg ( $path );
$isFile = instant_remote_process ([ " test -f { $escapedPath } && echo OK || echo NOK " ], $server );
2024-08-12 14:06:24 +00:00
if ( $isFile === 'OK' ) {
2025-11-27 13:36:31 +00:00
$content = instant_remote_process ([ " cat { $escapedPath } " ], $server , false );
2025-03-14 13:55:38 +00:00
// Check if content contains binary data by looking for null bytes or non-printable characters
if ( str_contains ( $content , " \0 " ) || preg_match ( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/' , $content )) {
$content = '[binary file]' ;
}
2024-08-12 14:06:24 +00:00
$this -> content = $content ;
$this -> is_directory = false ;
$this -> save ();
}
}
2024-04-15 17:47:17 +00:00
public function deleteStorageOnServer ()
{
2024-08-21 12:31:17 +00:00
$this -> load ([ 'service' ]);
2024-04-15 17:47:17 +00:00
$isService = data_get ( $this -> resource , 'service' );
if ( $isService ) {
$workdir = $this -> resource -> service -> workdir ();
$server = $this -> resource -> service -> server ;
} else {
$workdir = $this -> resource -> workdir ();
$server = $this -> resource -> destination -> server ;
}
2024-08-05 18:00:57 +00:00
$commands = collect ([]);
2024-08-12 14:06:24 +00:00
$path = data_get_str ( $this , 'fs_path' );
if ( $path -> startsWith ( '.' )) {
$path = $path -> after ( '.' );
$path = $workdir . $path ;
}
2025-11-27 13:36:31 +00:00
// Validate and escape path to prevent command injection
validateShellSafePath ( $path , 'storage path' );
$escapedPath = escapeshellarg ( $path );
$isFile = instant_remote_process ([ " test -f { $escapedPath } && echo OK || echo NOK " ], $server );
$isDir = instant_remote_process ([ " test -d { $escapedPath } && echo OK || echo NOK " ], $server );
2024-08-12 14:06:24 +00:00
if ( $path && $path != '/' && $path != '.' && $path != '..' ) {
2024-08-05 18:00:57 +00:00
if ( $isFile === 'OK' ) {
2025-11-27 13:36:31 +00:00
$commands -> push ( " rm -rf { $escapedPath } > /dev/null 2>&1 || true " );
2024-08-05 18:00:57 +00:00
} elseif ( $isDir === 'OK' ) {
2025-11-27 13:36:31 +00:00
$commands -> push ( " rm -rf { $escapedPath } > /dev/null 2>&1 || true " );
$commands -> push ( " rmdir { $escapedPath } > /dev/null 2>&1 || true " );
2024-08-05 18:00:57 +00:00
}
}
if ( $commands -> count () > 0 ) {
return instant_remote_process ( $commands , $server );
}
2024-04-15 17:47:17 +00:00
}
2024-06-10 20:43:34 +00:00
2024-02-14 14:00:24 +00:00
public function saveStorageOnServer ()
2023-09-29 19:38:11 +00:00
{
2024-08-12 14:06:24 +00:00
$this -> load ([ 'service' ]);
2024-04-10 13:00:46 +00:00
$isService = data_get ( $this -> resource , 'service' );
if ( $isService ) {
$workdir = $this -> resource -> service -> workdir ();
$server = $this -> resource -> service -> server ;
} else {
$workdir = $this -> resource -> workdir ();
$server = $this -> resource -> destination -> server ;
}
2024-08-05 18:00:57 +00:00
$commands = collect ([]);
if ( $this -> is_directory ) {
2024-02-14 14:00:24 +00:00
$commands -> push ( " mkdir -p $this->fs_path > /dev/null 2>&1 || true " );
2025-09-09 06:56:00 +00:00
$commands -> push ( " mkdir -p $workdir > /dev/null 2>&1 || true " );
2024-08-05 18:00:57 +00:00
$commands -> push ( " cd $workdir " );
2024-02-14 14:00:24 +00:00
}
if ( str ( $this -> fs_path ) -> startsWith ( '.' ) || str ( $this -> fs_path ) -> startsWith ( '/' ) || str ( $this -> fs_path ) -> startsWith ( '~' )) {
$parent_dir = str ( $this -> fs_path ) -> beforeLast ( '/' );
if ( $parent_dir != '' ) {
$commands -> push ( " mkdir -p $parent_dir > /dev/null 2>&1 || true " );
}
}
2024-08-12 14:06:24 +00:00
$path = data_get_str ( $this , 'fs_path' );
$content = data_get ( $this , 'content' );
2023-09-29 19:38:11 +00:00
if ( $path -> startsWith ( '.' )) {
$path = $path -> after ( '.' );
2024-06-10 20:43:34 +00:00
$path = $workdir . $path ;
2023-09-29 19:38:11 +00:00
}
2025-11-27 13:36:31 +00:00
// Validate and escape path to prevent command injection
validateShellSafePath ( $path , 'storage path' );
$escapedPath = escapeshellarg ( $path );
$isFile = instant_remote_process ([ " test -f { $escapedPath } && echo OK || echo NOK " ], $server );
$isDir = instant_remote_process ([ " test -d { $escapedPath } && echo OK || echo NOK " ], $server );
2024-10-31 14:23:19 +00:00
if ( $isFile === 'OK' && $this -> is_directory ) {
2025-11-27 13:36:31 +00:00
$content = instant_remote_process ([ " cat { $escapedPath } " ], $server , false );
2024-08-12 14:06:24 +00:00
$this -> is_directory = false ;
$this -> content = $content ;
$this -> save ();
2024-08-05 18:00:57 +00:00
FileStorageChanged :: dispatch ( data_get ( $server , 'team_id' ));
2025-01-07 14:31:43 +00:00
throw new \Exception ( 'The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.' );
} elseif ( $isDir === 'OK' && ! $this -> is_directory ) {
2024-10-31 14:23:19 +00:00
if ( $path === '/' || $path === '.' || $path === '..' || $path === '' || str ( $path ) -> isEmpty () || is_null ( $path )) {
2024-08-12 14:06:24 +00:00
$this -> is_directory = true ;
$this -> save ();
2025-01-07 14:31:43 +00:00
throw new \Exception ( 'The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.' );
2024-08-12 14:06:24 +00:00
}
instant_remote_process ([
2025-11-27 13:36:31 +00:00
" rm -fr { $escapedPath } " ,
" touch { $escapedPath } " ,
2024-08-12 14:06:24 +00:00
], $server , false );
FileStorageChanged :: dispatch ( data_get ( $server , 'team_id' ));
2023-09-29 19:38:11 +00:00
}
2024-10-31 14:23:19 +00:00
if ( $isDir === 'NOK' && ! $this -> is_directory ) {
2024-08-12 14:06:24 +00:00
$chmod = data_get ( $this , 'chmod' );
$chown = data_get ( $this , 'chown' );
2024-02-14 14:00:24 +00:00
if ( $content ) {
2025-09-15 15:56:48 +00:00
$content = base64_encode ( $content );
2025-11-27 13:36:31 +00:00
$commands -> push ( " echo ' $content ' | base64 -d | tee { $escapedPath } > /dev/null " );
2024-07-22 07:18:15 +00:00
} else {
2025-11-27 13:36:31 +00:00
$commands -> push ( " touch { $escapedPath } " );
2024-07-22 07:18:15 +00:00
}
2025-11-27 13:36:31 +00:00
$commands -> push ( " chmod +x { $escapedPath } " );
2024-07-22 07:18:15 +00:00
if ( $chown ) {
2025-11-27 13:36:31 +00:00
$commands -> push ( " chown $chown { $escapedPath } " );
2024-07-22 07:18:15 +00:00
}
if ( $chmod ) {
2025-11-27 13:36:31 +00:00
$commands -> push ( " chmod $chmod { $escapedPath } " );
2024-02-14 14:00:24 +00:00
}
2024-10-31 14:23:19 +00:00
} elseif ( $isDir === 'NOK' && $this -> is_directory ) {
2025-11-27 13:36:31 +00:00
$commands -> push ( " mkdir -p { $escapedPath } > /dev/null 2>&1 || true " );
2023-09-29 19:38:11 +00:00
}
2024-06-10 20:43:34 +00:00
2025-09-15 15:56:48 +00:00
return instant_remote_process ( $commands , $server );
2023-09-29 19:38:11 +00:00
}
2025-03-28 21:10:33 +00:00
// Accessor for convenient access
protected function plainMountPath () : Attribute
{
return Attribute :: make (
get : fn () => $this -> mount_path ,
set : fn ( $value ) => $this -> mount_path = $value
);
}
// Scope for searching
public function scopeWherePlainMountPath ( $query , $path )
{
return $query -> get () -> where ( 'plain_mount_path' , $path );
}
2025-10-03 14:39:57 +00:00
2025-12-11 20:25:33 +00:00
// Check if this volume belongs to a service resource
public function isServiceResource () : bool
{
return in_array ( $this -> resource_type , [
'App\Models\ServiceApplication' ,
'App\Models\ServiceDatabase' ,
]);
}
// Determine if this volume should be read-only in the UI
// File/directory mounts can be edited even for services
public function shouldBeReadOnlyInUI () : bool
{
// Check for explicit :ro flag in compose (existing logic)
return $this -> isReadOnlyVolume ();
}
2025-10-03 14:39:57 +00:00
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume () : bool
{
try {
// Only check for services
$service = $this -> service ;
if ( ! $service || ! method_exists ( $service , 'service' )) {
return false ;
}
$actualService = $service -> service ;
if ( ! $actualService || ! $actualService -> docker_compose_raw ) {
return false ;
}
// Parse the docker-compose content
$compose = Yaml :: parse ( $actualService -> docker_compose_raw );
if ( ! isset ( $compose [ 'services' ])) {
return false ;
}
// Find the service that this volume belongs to
$serviceName = $service -> name ;
if ( ! isset ( $compose [ 'services' ][ $serviceName ][ 'volumes' ])) {
return false ;
}
$volumes = $compose [ 'services' ][ $serviceName ][ 'volumes' ];
// Check each volume to find a match
2025-12-11 13:18:58 +00:00
// Note: We match on mount_path (container path) only, since fs_path gets transformed
// from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
2025-10-03 14:39:57 +00:00
foreach ( $volumes as $volume ) {
// Volume can be string like "host:container:ro" or "host:container"
if ( is_string ( $volume )) {
$parts = explode ( ':' , $volume );
2025-12-11 13:18:58 +00:00
// Check if this volume matches our mount_path
2025-10-03 14:39:57 +00:00
if ( count ( $parts ) >= 2 ) {
$containerPath = $parts [ 1 ];
$options = $parts [ 2 ] ? ? null ;
2025-12-11 20:25:33 +00:00
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str ( $this -> mount_path ) -> ltrim ( '/' ) -> toString ();
$containerPathClean = str ( $containerPath ) -> ltrim ( '/' ) -> toString ();
if ( $mountPath === $containerPathClean || $this -> mount_path === $containerPath ) {
2025-10-03 14:39:57 +00:00
return $options === 'ro' ;
}
}
2025-12-11 13:18:58 +00:00
} elseif ( is_array ( $volume )) {
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
$containerPath = data_get ( $volume , 'target' );
$readOnly = data_get ( $volume , 'read_only' , false );
2025-12-11 20:25:33 +00:00
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str ( $this -> mount_path ) -> ltrim ( '/' ) -> toString ();
$containerPathClean = str ( $containerPath ) -> ltrim ( '/' ) -> toString ();
if ( $mountPath === $containerPathClean || $this -> mount_path === $containerPath ) {
2025-12-11 13:18:58 +00:00
return $readOnly === true ;
}
2025-10-03 14:39:57 +00:00
}
}
return false ;
} catch ( \Throwable $e ) {
ray ( $e -> getMessage (), 'Error checking read-only volume' );
return false ;
}
}
2023-09-22 19:31:47 +00:00
}