@@ -3872,11 +3872,306 @@ function createFile(string $type): void {
38723872 }
38733873 }
38743874
3875+ function fvplusAllowedCustomIconExtensions (): array {
3876+ return ['png ' , 'jpg ' , 'jpeg ' , 'gif ' , 'webp ' , 'svg ' , 'bmp ' , 'ico ' , 'avif ' ];
3877+ }
3878+
38753879 function fvplusCustomIconDirPath (): string {
3880+ global $ configDir ;
3881+ return "$ configDir/images/custom " ;
3882+ }
3883+
3884+ function fvplusCustomIconRuntimeDirPath (): string {
38763885 global $ sourceDir ;
38773886 return "$ sourceDir/images/custom " ;
38783887 }
38793888
3889+ function fvplusCustomIconRepairHintCommand (): string {
3890+ $ dir = fvplusCustomIconDirPath ();
3891+ return "mkdir -p " . escapeshellarg ($ dir ) . " && chmod -R 775 " . escapeshellarg ($ dir );
3892+ }
3893+
3894+ function fvplusShouldManageCustomIconStorageEntry (string $ name , bool $ includeMetadata = true ): bool {
3895+ $ safeName = trim ($ name );
3896+ if ($ safeName === '' || $ safeName !== basename ($ safeName ) || $ safeName === '. ' || $ safeName === '.. ' ) {
3897+ return false ;
3898+ }
3899+ if ($ includeMetadata && $ safeName === '.metadata.json ' ) {
3900+ return true ;
3901+ }
3902+ if ($ safeName === 'README.txt ' ) {
3903+ return true ;
3904+ }
3905+ $ extension = strtolower ((string )pathinfo ($ safeName , PATHINFO_EXTENSION ));
3906+ return $ extension !== '' && in_array ($ extension , fvplusAllowedCustomIconExtensions (), true );
3907+ }
3908+
3909+ function fvplusCopyCustomIconStorageFile (string $ sourcePath , string $ targetPath ): bool {
3910+ $ targetDir = dirname ($ targetPath );
3911+ if (!is_dir ($ targetDir )) {
3912+ @mkdir ($ targetDir , 0770 , true );
3913+ }
3914+ if (!@copy ($ sourcePath , $ targetPath )) {
3915+ return false ;
3916+ }
3917+ $ mtime = (int )@filemtime ($ sourcePath );
3918+ if ($ mtime > 0 ) {
3919+ @touch ($ targetPath , $ mtime );
3920+ }
3921+ @chmod ($ targetPath , 0644 );
3922+ return true ;
3923+ }
3924+
3925+ function fvplusListCustomIconStorageEntries (string $ directory , bool $ includeMetadata = true ): array {
3926+ $ entries = [];
3927+ if (!is_dir ($ directory )) {
3928+ return $ entries ;
3929+ }
3930+ foreach ((array )@scandir ($ directory ) as $ name ) {
3931+ if (!fvplusShouldManageCustomIconStorageEntry ((string )$ name , $ includeMetadata )) {
3932+ continue ;
3933+ }
3934+ $ path = "$ directory/ $ name " ;
3935+ if (!is_file ($ path )) {
3936+ continue ;
3937+ }
3938+ $ entries [(string )$ name ] = [
3939+ 'path ' => $ path ,
3940+ 'mtime ' => max (0 , (int )@filemtime ($ path )),
3941+ 'size ' => max (0 , (int )@filesize ($ path ))
3942+ ];
3943+ }
3944+ return $ entries ;
3945+ }
3946+
3947+ function fvplusMigrateRuntimeCustomIconsToPersistent (): array {
3948+ $ runtimeDir = fvplusCustomIconRuntimeDirPath ();
3949+ $ storageDir = fvplusCustomIconDirPath ();
3950+ $ migratedFiles = [];
3951+ $ migratedMetadata = false ;
3952+ if (!is_dir ($ runtimeDir ) || is_link ($ runtimeDir )) {
3953+ return [
3954+ 'migratedFileCount ' => 0 ,
3955+ 'migratedMetadata ' => false ,
3956+ 'migratedFiles ' => []
3957+ ];
3958+ }
3959+ if (!is_dir ($ storageDir )) {
3960+ @mkdir ($ storageDir , 0770 , true );
3961+ }
3962+ $ runtimeEntries = fvplusListCustomIconStorageEntries ($ runtimeDir , true );
3963+ foreach ($ runtimeEntries as $ name => $ entry ) {
3964+ if ($ name === 'README.txt ' ) {
3965+ continue ;
3966+ }
3967+ $ sourcePath = (string )($ entry ['path ' ] ?? '' );
3968+ if ($ sourcePath === '' ) {
3969+ continue ;
3970+ }
3971+ $ targetPath = "$ storageDir/ $ name " ;
3972+ $ shouldCopy = !is_file ($ targetPath );
3973+ if (!$ shouldCopy ) {
3974+ $ targetSize = max (0 , (int )@filesize ($ targetPath ));
3975+ $ targetMtime = max (0 , (int )@filemtime ($ targetPath ));
3976+ $ shouldCopy = $ targetSize !== (int )($ entry ['size ' ] ?? 0 ) || $ targetMtime < (int )($ entry ['mtime ' ] ?? 0 );
3977+ }
3978+ if (!$ shouldCopy ) {
3979+ continue ;
3980+ }
3981+ if (!fvplusCopyCustomIconStorageFile ($ sourcePath , $ targetPath )) {
3982+ continue ;
3983+ }
3984+ if ($ name === '.metadata.json ' ) {
3985+ $ migratedMetadata = true ;
3986+ } else {
3987+ $ migratedFiles [] = $ name ;
3988+ }
3989+ }
3990+ return [
3991+ 'migratedFileCount ' => count ($ migratedFiles ),
3992+ 'migratedMetadata ' => $ migratedMetadata ,
3993+ 'migratedFiles ' => array_values ($ migratedFiles )
3994+ ];
3995+ }
3996+
3997+ function fvplusRemoveRuntimeCustomIconDirForLink (): bool {
3998+ $ runtimeDir = fvplusCustomIconRuntimeDirPath ();
3999+ if (is_link ($ runtimeDir )) {
4000+ return @unlink ($ runtimeDir );
4001+ }
4002+ if (!is_dir ($ runtimeDir )) {
4003+ return !file_exists ($ runtimeDir );
4004+ }
4005+ foreach ((array )@scandir ($ runtimeDir ) as $ name ) {
4006+ if ($ name === '. ' || $ name === '.. ' ) {
4007+ continue ;
4008+ }
4009+ if (!fvplusShouldManageCustomIconStorageEntry ((string )$ name , true )) {
4010+ return false ;
4011+ }
4012+ $ path = "$ runtimeDir/ $ name " ;
4013+ if (!is_file ($ path ) || !@unlink ($ path )) {
4014+ return false ;
4015+ }
4016+ }
4017+ return @rmdir ($ runtimeDir );
4018+ }
4019+
4020+ function fvplusMirrorPersistentCustomIconsToRuntime (): array {
4021+ $ storageDir = fvplusCustomIconDirPath ();
4022+ $ runtimeDir = fvplusCustomIconRuntimeDirPath ();
4023+ $ copied = 0 ;
4024+ $ pruned = 0 ;
4025+ $ runtimeParent = dirname ($ runtimeDir );
4026+ if (!is_dir ($ runtimeParent )) {
4027+ @mkdir ($ runtimeParent , 0770 , true );
4028+ }
4029+ if (!is_dir ($ runtimeDir )) {
4030+ @mkdir ($ runtimeDir , 0770 , true );
4031+ }
4032+ if (!is_dir ($ runtimeDir )) {
4033+ return [
4034+ 'copiedCount ' => 0 ,
4035+ 'prunedCount ' => 0 ,
4036+ 'ready ' => false
4037+ ];
4038+ }
4039+
4040+ $ storageEntries = fvplusListCustomIconStorageEntries ($ storageDir , false );
4041+ $ runtimeEntries = fvplusListCustomIconStorageEntries ($ runtimeDir , false );
4042+
4043+ foreach ($ storageEntries as $ name => $ entry ) {
4044+ $ sourcePath = (string )($ entry ['path ' ] ?? '' );
4045+ $ targetPath = "$ runtimeDir/ $ name " ;
4046+ $ targetExists = is_file ($ targetPath );
4047+ $ targetSize = $ targetExists ? max (0 , (int )@filesize ($ targetPath )) : -1 ;
4048+ $ targetMtime = $ targetExists ? max (0 , (int )@filemtime ($ targetPath )) : -1 ;
4049+ if ($ targetExists && $ targetSize === (int )($ entry ['size ' ] ?? 0 ) && $ targetMtime >= (int )($ entry ['mtime ' ] ?? 0 )) {
4050+ continue ;
4051+ }
4052+ if (fvplusCopyCustomIconStorageFile ($ sourcePath , $ targetPath )) {
4053+ $ copied ++;
4054+ }
4055+ }
4056+
4057+ foreach ($ runtimeEntries as $ name => $ _entry ) {
4058+ if (isset ($ storageEntries [$ name ])) {
4059+ continue ;
4060+ }
4061+ if (@unlink ("$ runtimeDir/ $ name " )) {
4062+ $ pruned ++;
4063+ }
4064+ }
4065+
4066+ return [
4067+ 'copiedCount ' => $ copied ,
4068+ 'prunedCount ' => $ pruned ,
4069+ 'ready ' => is_dir ($ runtimeDir ) && is_readable ($ runtimeDir )
4070+ ];
4071+ }
4072+
4073+ function fvplusEnsureCustomIconStorageReady (bool $ requireWritable = false ): array {
4074+ $ storageDir = fvplusCustomIconDirPath ();
4075+ $ runtimeDir = fvplusCustomIconRuntimeDirPath ();
4076+ $ storageParent = dirname ($ storageDir );
4077+ $ runtimeParent = dirname ($ runtimeDir );
4078+ $ repairAttempted = false ;
4079+
4080+ if (!is_dir ($ storageParent )) {
4081+ $ repairAttempted = true ;
4082+ @mkdir ($ storageParent , 0770 , true );
4083+ }
4084+ if (!is_dir ($ storageDir )) {
4085+ $ repairAttempted = true ;
4086+ @mkdir ($ storageDir , 0770 , true );
4087+ }
4088+ if (is_dir ($ storageDir ) && !is_writable ($ storageDir )) {
4089+ $ repairAttempted = true ;
4090+ @chmod ($ storageDir , 0770 );
4091+ }
4092+
4093+ $ storageExists = is_dir ($ storageDir );
4094+ $ storageWritable = $ storageExists && is_writable ($ storageDir );
4095+ if (!$ storageExists ) {
4096+ throw new RuntimeException ('Custom icon directory does not exist. Run: ' . fvplusCustomIconRepairHintCommand ());
4097+ }
4098+ if ($ requireWritable && !$ storageWritable ) {
4099+ throw new RuntimeException ('Custom icon directory is not writable. Run: ' . fvplusCustomIconRepairHintCommand ());
4100+ }
4101+
4102+ $ migration = fvplusMigrateRuntimeCustomIconsToPersistent ();
4103+ $ repairAttempted = $ repairAttempted
4104+ || ((int )($ migration ['migratedFileCount ' ] ?? 0 ) > 0 )
4105+ || (($ migration ['migratedMetadata ' ] ?? false ) === true );
4106+
4107+ $ publicMode = 'missing ' ;
4108+ $ publicReady = false ;
4109+ if (is_link ($ runtimeDir )) {
4110+ $ runtimeResolved = @realpath ($ runtimeDir );
4111+ $ storageResolved = @realpath ($ storageDir );
4112+ if (is_string ($ runtimeResolved ) && $ runtimeResolved !== '' && $ runtimeResolved === $ storageResolved ) {
4113+ $ publicMode = 'symlink ' ;
4114+ $ publicReady = true ;
4115+ } else {
4116+ $ repairAttempted = true ;
4117+ @unlink ($ runtimeDir );
4118+ }
4119+ }
4120+
4121+ $ symlinkCreated = false ;
4122+ if (!$ publicReady && function_exists ('symlink ' )) {
4123+ if (!is_dir ($ runtimeParent )) {
4124+ $ repairAttempted = true ;
4125+ @mkdir ($ runtimeParent , 0770 , true );
4126+ }
4127+ if (!file_exists ($ runtimeDir ) || fvplusRemoveRuntimeCustomIconDirForLink ()) {
4128+ $ repairAttempted = true ;
4129+ $ symlinkCreated = @symlink ($ storageDir , $ runtimeDir );
4130+ }
4131+ if ($ symlinkCreated ) {
4132+ $ publicMode = 'symlink ' ;
4133+ $ publicReady = true ;
4134+ }
4135+ }
4136+
4137+ $ mirror = ['copiedCount ' => 0 , 'prunedCount ' => 0 , 'ready ' => false ];
4138+ if (!$ publicReady ) {
4139+ $ repairAttempted = true ;
4140+ $ mirror = fvplusMirrorPersistentCustomIconsToRuntime ();
4141+ $ publicMode = 'mirror ' ;
4142+ $ publicReady = ($ mirror ['ready ' ] ?? false ) === true ;
4143+ }
4144+
4145+ return [
4146+ 'storageDir ' => $ storageDir ,
4147+ 'runtimeDir ' => $ runtimeDir ,
4148+ 'storageExists ' => $ storageExists ,
4149+ 'storageWritable ' => $ storageWritable ,
4150+ 'publicMode ' => $ publicMode ,
4151+ 'publicReady ' => $ publicReady ,
4152+ 'repairAttempted ' => $ repairAttempted ,
4153+ 'repairSucceeded ' => $ storageExists && (!$ requireWritable || $ storageWritable ) && $ publicReady ,
4154+ 'repairHint ' => fvplusCustomIconRepairHintCommand (),
4155+ 'migratedFileCount ' => (int )($ migration ['migratedFileCount ' ] ?? 0 ),
4156+ 'migratedMetadata ' => ($ migration ['migratedMetadata ' ] ?? false ) === true ,
4157+ 'mirrorCopiedCount ' => (int )($ mirror ['copiedCount ' ] ?? 0 ),
4158+ 'mirrorPrunedCount ' => (int )($ mirror ['prunedCount ' ] ?? 0 )
4159+ ];
4160+ }
4161+
4162+ function fvplusBootstrapCustomIconStorage (): void {
4163+ static $ bootstrapped = false ;
4164+ if ($ bootstrapped ) {
4165+ return ;
4166+ }
4167+ $ bootstrapped = true ;
4168+ try {
4169+ fvplusEnsureCustomIconStorageReady (false );
4170+ } catch (Throwable $ _error ) {
4171+ // Keep runtime bootstrap non-fatal; diagnostics will surface path issues.
4172+ }
4173+ }
4174+
38804175 function fvplusRepairMissingCustomIconReferences (): array {
38814176 $ customIconDir = fvplusCustomIconDirPath ();
38824177 $ existingIcons = [];
@@ -4049,13 +4344,13 @@ function repairPluginPaths(): array {
40494344 $ created [] = $ path ;
40504345 }
40514346 }
4052- $ customIconDir = fvplusCustomIconDirPath ( );
4053- if (! is_dir ( $ customIconDir )) {
4054- @ mkdir ( $ customIconDir , 0770 , true );
4055- $ created [] = $ customIconDir ;
4056- }
4057- if ( is_dir ( $ customIconDir )) {
4058- @ chmod ( $ customIconDir , 0770 );
4347+ $ customIconHealth = fvplusEnsureCustomIconStorageReady ( false );
4348+ $ customIconDir = ( string )( $ customIconHealth [ ' storageDir ' ] ?? fvplusCustomIconDirPath ());
4349+ $ customIconRuntimeDir = ( string )( $ customIconHealth [ ' runtimeDir ' ] ?? fvplusCustomIconRuntimeDirPath () );
4350+ foreach ([ $ customIconDir , dirname ( $ customIconDir )] as $ path ) {
4351+ if ( is_string ( $ path ) && $ path !== '' && ! in_array ( $ path , $ created , true ) && file_exists ( $ path )) {
4352+ $ created [] = $ path ;
4353+ }
40594354 }
40604355 foreach (FVPLUS_ALLOWED_TYPES as $ type ) {
40614356 createFile ($ type );
@@ -4064,10 +4359,13 @@ function repairPluginPaths(): array {
40644359 return [
40654360 'createdPaths ' => $ created ,
40664361 'configDir ' => $ configDir ,
4067- 'customIconDir ' => $ customIconDir
4362+ 'customIconDir ' => $ customIconDir ,
4363+ 'customIconRuntimeDir ' => $ customIconRuntimeDir
40684364 ];
40694365 }
40704366
4367+ fvplusBootstrapCustomIconStorage ();
4368+
40714369 function getDockerTemplateCachePath (): string {
40724370 return fv3_cache_root () . '/docker-template-index/cache.json ' ;
40734371 }
0 commit comments