HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Model/Language.php
<?php

/**
 * Language class.
 *
 * Model class for languages
 *
 * LICENSE: This product includes software developed at
 * the Acelle Co., Ltd. (http://acellemail.com/).
 *
 * @category   MVC Model
 *
 * @author     N. Pham <[email protected]>
 * @author     L. Pham <[email protected]>
 * @copyright  Acelle Co., Ltd
 * @license    Acelle Co., Ltd
 *
 * @version    1.0
 *
 * @link       http://acellemail.com
 */

namespace App\Model;

use Illuminate\Database\Eloquent\Model;
use App\Library\Facades\Hook;
use App\Library\Traits\HasUid;
use Exception;
use DB;

class Language extends Model
{
    use HasUid;

    public const STATUS_ACTIVE = 'active';
    public const STATUS_INACTIVE = 'inactive';

    /**
     * Per-file report produced by the last upload() call.
     * Declared as a public property so it does not hit Eloquent's attribute magic.
     *
     * Shape:
     *   [
     *     'files' => [
     *       [
     *         'id' => string,
     *         'title' => string,
     *         'status' => 'ok' | 'locked' | 'restored_missing' | 'skipped' | 'error',
     *         'details' => array,
     *       ],
     *       ...
     *     ],
     *     'summary' => [
     *       'total' => int,
     *       'ok' => int,
     *       'locked' => int,
     *       'restored_missing' => int,
     *       'skipped' => int,
     *       'errors' => int,
     *     ],
     *   ]
     */
    public $uploadReport = null;

    /**
     * Get users.
     *
     * @return mixed
     */
    public function users()
    {
        return $this->hasMany('App\Model\User');
    }

    /**
     * Customer association.
     *
     * @return mixed
     */
    public function customers()
    {
        return $this->hasMany('App\Model\Customer');
    }

    /**
     * Admin association.
     *
     * @return mixed
     */
    public function admins()
    {
        return $this->hasMany('App\Model\Admin');
    }

    /**
     * Language folder path.
     *
     * @return string
     */
    public function languageDir()
    {
        return resource_path(join_paths('lang', $this->code));
    }

    public static function getDirWhichNewLanguageCopyFrom()
    {
        return base_path('resources/lang/default');
    }

    public function scopeActive($query)
    {
        $query->where('status', '=', self::STATUS_ACTIVE);
    }

    /**
     * Get select options.
     *
     * @return array
     */
    public static function getSelectOptions()
    {
        $options = self::active()->get()->map(function ($item) {
            return ['value' => $item->id, 'text' => $item->name];
        });

        // japan only en and ja
        if (config('custom.japan')) {
            $options = self::active()->get()->filter(function ($item) {
                return in_array($item->code, ['en','ja']);
            })->map(function ($item) {
                return ['value' => $item->id, 'text' => $item->name];
            });
        }

        return $options->values()->all();
    }

    /**
     * Search items.
     *
     * @return collect
     */
    public function scopeSearch($query, $keyword)
    {
        // Keyword
        if (!empty(trim($keyword))) {
            $keyword = trim($keyword);
            foreach (explode(' ', $keyword) as $keyword) {
                $query = $query->where(function ($q) use ($keyword) {
                    $q->orwhere('languages.name', 'like', '%'.$keyword.'%')
                        ->orwhere('languages.code', 'like', '%'.$keyword.'%')
                        ->orwhere('languages.region_code', 'like', '%'.$keyword.'%');
                });
            }
        }
    }

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'code', 'region_code', 'status'
    ];

    /**
     * Get validation rules.
     *
     * @return object
     */
    public function rules()
    {
        return [
            'name' => 'required',
            'code' => 'required|unique:languages,code,'.$this->id,
        ];
    }

    public function scopeDefault($query)
    {
        return $query->where('is_default', '=', true);
    }

    /**
     * Get is default language.
     *
     * @var object
     */
    public static function getFirstDefaultLanguage()
    {
        return self::default()->first();
    }

    /**
     * Get locale array from file.
     *
     * @var array
     */
    public function getLocaleArrayFromFile($filename)
    {
        clearstatcache();
        if (function_exists('opcache_invalidate')) {
            opcache_invalidate(join_paths($this->languageDir(), $filename.'.php'));
        }

        $arr = self::fileToArray(join_paths($this->languageDir(), $filename.'.php'));
        return $arr;
    }

    /**
     * Read locale file.
     *
     * @var text
     */
    public function readLocaleFile($filename)
    {
        $text = \Illuminate\Support\Facades\File::get(join_paths($this->languageDir(), $filename.'.php'));

        return $text;
    }

    /**
     * Read locale file.
     *
     * @var text
     */
    public function localeToYaml($filename)
    {
        $text = $this->readLocaleFile($filename);

        return yaml_parse($text);
    }

    /**
     * Update language file from yaml.
     *
     * @var text
     */
    public function updateFromYaml($filename, $yaml)
    {
        self::yamlToFile(join_paths($this->languageDir(), $filename.'.php'), $yaml);
    }

    /**
     * Update language file from yaml.
     *
     * @var text
     */
    public function getBuilderLang()
    {
        return include join_paths($this->languageDir(), 'builder.php');
    }

    /**
     * all language code.
     *
     * @return array
     */
    public static function languageCodes()
    {
        $arr = config('languages');

        if (config('custom.japan')) {
            $arr = [
                'en' => 'English',
                'ja' => 'Japanese (ja)',
            ];
        }

        $result = [];
        foreach ($arr as $key => $name) {
            $result[] = [
                'text' => strtoupper($key).' / '.$name,
                'value' => $key,
            ];
        }

        return $result;
    }

    /**
     * Disable language.
     *
     * @return array
     */
    public function disable()
    {
        $this->status = self::STATUS_INACTIVE;
        $this->save();
    }

    /**
     * Enable language.
     *
     * @return array
     */
    public function enable()
    {
        $this->status = self::STATUS_ACTIVE;
        $this->save();
    }

    public static function fileToArray($pathToFile)
    {
        return \Illuminate\Support\Facades\File::getRequire($pathToFile);
    }

    public static function arrayToYaml($array)
    {
        return \Yaml::dump($array);
    }

    public static function fileToYaml($path)
    {
        return self::arrayToYaml(self::fileToArray($path));
    }

    public static function yamlToFile($pathToFile, $yaml)
    {
        $content = '<?php return '.var_export(\Yaml::parse($yaml), true).' ?>';
        $bytes_written = \Illuminate\Support\Facades\File::put($pathToFile, $content);
    }

    // Actually it list translation files registered via the Hook `add_translation_file`, but we call it "language files" for simplicity
    // one language may have multiple translation files, for example: messages.php, validation.php, warmup.php, refactor/account.php, etc
    // This is a shortcut to get all translation files for a language, with additional information like file type (default vs plugin), file title, etc
    // The most important attribute is $path, which is the real file path of this translation file, so it can be read/written when translating or updating translation files
    public function getAllLanguageFiles()
    {
        // Returns all translation files registered for the current language,
        // keyed by the hook `id`. Example:
        //
        //     [
        //         'messages' => [
        //             'id'                      => 'messages',
        //             'type'                    => 'default',   // default | plugin
        //             'path'                    => '/acellemail/resources/lang/en/messages.php',
        //             'file_title'              => 'messages.php',
        //             'master_translation_file' => '/acellemail/resources/lang/default/messages.php',
        //         ],
        //         'validation' => [ ... ],
        //         ...
        //     ]

        $paths = [];

        $files = Hook::collect('add_translation_file');
        foreach ($files as $file) {
            if (isset($paths[$file['id']])) {
                // Some PHP-version / boot-order combinations cause the same
                // ServiceProvider::boot() to run twice (observed on PHP 8.2-FPM
                // for App\Cashier\CashierServiceProvider), producing duplicate
                // Hook::add('add_translation_file', …) entries with identical
                // payload. Treat exact-duplicate id as benign: log + skip. If
                // the duplicate carries DIFFERENT paths, that's a real plugin
                // conflict → still throw.
                if ($paths[$file['id']]['file_title'] === $file['file_title']) {
                    \Log::warning('Duplicate translation file registration skipped: '.$file['id']);
                    continue;
                }
                throw new \Exception('Translation file id collision (different payload): ' . $file['id']);
            }

            list($masterFile, $langFile) = $this->resolveTranslationFilePaths($file);

            $paths[$file['id']] = [
                'id' => $file['id'],
                'type' => isset($file['type']) ? $file['type'] : 'plugin',
                'path' => $langFile,
                'file_title' => $file['file_title'],
                'master_translation_file' => $masterFile,
            ];
        }

        return $paths;
    }

    /**
     * Lock keys: returns a new array shaped like $master but with scalar values
     * replaced by the corresponding value from $user (when present).
     *
     * - Keys that exist only in $user are dropped (can't add new keys).
     * - Keys that exist only in $master are kept (can't delete keys).
     * - Nested arrays recurse the same rule.
     */
    protected static function lockKeysToMaster(array $master, array $user): array
    {
        $result = [];
        foreach ($master as $key => $masterValue) {
            if (is_array($masterValue)) {
                $userValue = (isset($user[$key]) && is_array($user[$key])) ? $user[$key] : [];
                $result[$key] = self::lockKeysToMaster($masterValue, $userValue);
            } else {
                $result[$key] = (array_key_exists($key, $user) && !is_array($user[$key]))
                    ? $user[$key]
                    : $masterValue;
            }
        }
        return $result;
    }

    public function getLanguageFilesByType($type)
    {
        $langFiles = $this->getAllLanguageFiles();
        foreach ($langFiles as $key => $langFile) {
            if ($langFile['type'] != $type) {
                unset($langFiles[$key]);
            }
        }

        return $langFiles;
    }

    public function getLanguageFileOptions()
    {
        $arr = [];
        foreach ($this->getAllLanguageFiles() as $key => $langFile) {
            $arr[] = ['value' => $langFile['id'], 'text' => $langFile['file_title']];
            ;
        }

        return $arr;
    }

    public static function newDefaultLanguage()
    {
        $language = new self();
        $language->status = self::STATUS_ACTIVE;

        return $language;
    }

    // @todo 'dump' is just an alias for many tasks that may be involved
    // + create missing translation files
    // + update existing translation files from its original source
    // + more
    public static function dump()
    {
        // Update or create translation files
        foreach (self::get() as $language) {
            $language->createOrUpdateTranslationFiles();
        }
    }

    public function detectNewPhrases()
    {
        foreach ($this->getAllLanguageFiles() as $fileId => $file) {
            $masterArray = include $file['master_translation_file'];
            $langArray = include $file['path'];

            $diff = array_diff_key($masterArray, $langArray);

            if (!empty($diff)) {
                foreach ($diff as $key => $value) {
                    TranslationPhrase::firstOrCreate([
                        'file' => $fileId,
                        'key' => $key,
                    ]);
                }
            }
        }
    }

    /**
     * Resolve the master (origin) and target (language) file paths for
     * a given Hook source. Pure path resolution — no side effects.
     *
     * @return array{string, string} [$masterFile, $langFile]
     */
    public function resolveTranslationFilePaths($source)
    {
        if (!array_key_exists('master_translation_file', $source)) {
            throw new Exception('[master_translation_file] is not available for '.$source['file_name']);
        }

        if (array_key_exists('master_translation_file_by_language', $source) && array_key_exists($this->code, $source['master_translation_file_by_language'])) {
            $masterFile = $source['master_translation_file_by_language'][$this->code];
        } else {
            $masterFile = $source['master_translation_file'];
        }

        if ($masterFile == false) {
            throw new Exception(sprintf('%s: master translation file does not exist: "%s". File path returns false! Make sure realpath() returns a valid file path.', $this->name, $source["file_title"]));
        }

        $langFile = join_paths($source['translation_folder'], $this->code, $source['file_name']);

        if (!file_exists($masterFile)) {
            throw new Exception($this->name.': Master translation file does not exist: "'.$masterFile.'". Make sure it is registered correctly');
        }

        return [$masterFile, $langFile];
    }

    /**
     * Create (if missing) and sync a translation file from its master.
     */
    public function createTranslationFile($source)
    {
        list($masterFile, $langFile) = $this->resolveTranslationFilePaths($source);

        if (!file_exists($langFile)) {
            \App\Helpers\pcopy($masterFile, $langFile);
        }

        // Sync target file with master: merge new keys, drop removed keys.
        // Skip when origin and target resolve to the same physical file.
        // A plugin can register master_translation_file pointing to an
        // existing language file (reusing it as its own master). Without
        // this guard, updateTranslationFile would overwrite the file with
        // a var_export'd copy of itself, destroying formatting and content.
        if (realpath($masterFile) != realpath($langFile)) {
            \App\Helpers\updateTranslationFile($langFile, $masterFile, $overwrite = false, $deleteTargetKeys = true, $sort = true);
        }

        return [$masterFile, $langFile];
    }

    /*
     * Hook::collect("add_translation_file") returns an array of translation files registered by plugins and the system.
     * For example: 
     *  [
            "id" => "acelle_warmup",
            "plugin_name" => "Application/Core",
            "file_title" => "Warmup",
            "translation_folder" => "/home/nghi/mailixa/resources/lang",
            "file_name" => "warmup.php",
            "master_translation_file" => "/home/nghi/mailixa/resources/lang/default/warmup.php",
        ], [
            "id" => "acelle_refactor_account",
            "plugin_name" => "Application/Core",
            "file_title" => "Refactor — Account",
            "translation_folder" => "/home/nghi/mailixa/resources/lang",
            "file_name" => "refactor/account.php",
            "master_translation_file" => "/home/nghi/mailixa/resources/lang/default/refactor/account.php",
        ], [
        ...
    */

    public function createOrUpdateTranslationFiles()
    {
        foreach (Hook::collect('add_translation_file') as $source) {
            $this->createTranslationFile($source);
        }
    }

    public static function createFromArray($attributes)
    {
        $language = self::newDefaultLanguage();

        $language->fill($attributes);
        $language->status = self::STATUS_INACTIVE;

        // make validator
        $validator = \Validator::make($attributes, $language->rules());

        // redirect if fails
        if ($validator->fails()) {
            return [$language, $validator];
        }

        DB::transaction(function () use (&$language) {
            // save
            $language->save();

            // Create translation files for this newly created language
            $language->createOrUpdateTranslationFiles();
        });

        return [$language, true];
    }

    public function updateFromRequest($request)
    {
        // make validator
        $validator = \Validator::make($request->all(), $this->rules());

        // redirect if fails
        if ($validator->fails()) {
            return $validator;
        }

        // rename locale folder
        if ($this->code != $request->code) {
            rename(base_path("resources/lang/") . $this->code, base_path("resources/lang/") . $request->code);
        }

        $this->fill($request->all());

        // save
        $this->save();

        return true;
    }

    public function deleteAndCleanup()
    {
        // Change deleting language's users to the default langauge
        $default_language = self::getFirstDefaultLanguage();

        if (!$default_language) {
            throw new \Exception('Something went wrong! Can not find the default language.');
        }

        $this->customers()->update(['language_id' => $default_language->id]);
        $this->admins()->update(['language_id' => $default_language->id]);

        // delete language folder
        $des = $this->languageDir();
        if (file_exists($des)) {
            \App\Library\Tool::xdelete($des);
        }

        $this->delete();
    }

    public function translateFile($fileId, $content)
    {
        $file = $this->findFileById($fileId);

        $validator = \Validator::make(['content' => $content], [
            'content' => 'required',
        ]);

        $validator->after(function ($validator) use ($content) {
            try {
                \Yaml::parse($content);
            } catch (\Exception $e) {
                $validator->errors()->add('content', $e->getMessage());
            }
        });

        if ($validator->fails()) {
            return [$file, $validator];
        }

        // Enforce key integrity against the master translation file
        // (usually lang/default/*.php). Keys the admin added are stripped;
        // keys the admin deleted are restored from the master. Only the
        // VALUES of existing keys can be edited.
        $masterFile = $file['master_translation_file'] ?? null;
        if ($masterFile && file_exists($masterFile)) {
            $masterArray = include $masterFile;
            $submitted = \Yaml::parse($content);
            if (!is_array($submitted)) {
                $submitted = [];
            }
            $locked = self::lockKeysToMaster(
                is_array($masterArray) ? $masterArray : [],
                $submitted
            );
            $content = self::arrayToYaml($locked);
        }
        self::yamlToFile($file['path'], $content);

        return [$file, $validator];
    }

    // Throw exception
    public static function getFirstLanguageByCode($code)
    {
        $lang = self::where('code', $code)->first();
        return $lang;
    }

    public function findFileById($id)
    {
        if (!isset($this->getAllLanguageFiles()[$id])) {
            throw new \Exception('Can not find translation file with id: ' . $id);
        }

        return $this->getAllLanguageFiles()[$id];
    }

    public function getDefaultFile()
    {
        $files = $this->getAllLanguageFiles();
        return array_shift($files);
    }

    /**
     * Upload a language package (ZIP).
     *
     * Strategy (safe, per-file):
     *   1. Validate + extract ZIP to an isolated temp dir (NEVER touch languageDir directly).
     *   2. For each translation file registered via the Hook `add_translation_file`:
     *      - If file missing from ZIP    → restore from master (lang/default/...).
     *      - If file has PHP syntax error → skip, report error (keep existing file).
     *      - Otherwise                   → lock keys to master array shape
     *                                      (extras dropped, missing restored).
     *   3. Clean up temp dir.
     *   4. Report per-file status via `$this->uploadReport`.
     *
     * @param \Illuminate\Http\Request $request
     * @return \Illuminate\Contracts\Validation\Validator
     */
    public function upload($request)
    {
        $this->uploadReport = [
            'files' => [],
            'summary' => [
                'total' => 0,
                'ok' => 0,
                'locked' => 0,
                'restored_missing' => 0,
                'skipped' => 0,
                'errors' => 0,
            ],
        ];

        $validator = \Validator::make($request->all(), [
            'file' => 'required',
        ]);

        $validator->after(function ($validator) use ($request) {
            $file_ext = $request->file('file')->guessExtension();
            if ($file_ext !== 'zip') {
                $validator->errors()->add('content', 'Upload file is not zip file');
                return;
            }

            $tmp_path = storage_path('tmp');
            if (!is_dir($tmp_path)) {
                mkdir($tmp_path, 0755, true);
            }
            $file_name = 'language-package-' . uniqid();
            $request->file('file')->move($tmp_path, $file_name);
            $tmp_zip = storage_path("tmp/{$file_name}");

            $zip = new \ZipArchive();
            if ($zip->open($tmp_zip) !== true) {
                @unlink($tmp_zip);
                $validator->errors()->add('content', 'Upload file is not a valid archive file');
                return;
            }

            $tmpExtract = storage_path('tmp/lang-upload-' . uniqid());
            $zip->extractTo($tmpExtract);
            $zip->close();
            @unlink($tmp_zip);

            try {
                $this->processExtractedLanguagePackage($tmpExtract);
            } catch (\Throwable $e) {
                $validator->errors()->add('content', 'Failed to process uploaded package: ' . $e->getMessage());
            } finally {
                \Illuminate\Support\Facades\File::deleteDirectory($tmpExtract);
            }
        });

        return $validator;
    }

    /**
     * Walk through the extracted temp dir and apply each registered translation file
     * to the real language dir, using master as the source of truth for keys + files.
     */
    protected function processExtractedLanguagePackage(string $tmpExtract): void
    {
        $langDir = $this->languageDir();
        $langDirPrefix = $langDir . '/';
        $langDirPrefixLen = strlen($langDirPrefix);

        foreach ($this->getAllLanguageFiles() as $fileId => $file) {
            $targetPath = $file['path'];

            // Only process files that live inside this language's dir.
            // External plugin translation files (absolute paths outside resource_path/lang/{code})
            // are skipped silently — they aren't shipped in the ZIP.
            if (strncmp($targetPath, $langDirPrefix, $langDirPrefixLen) !== 0) {
                continue;
            }

            $relativePath = substr($targetPath, $langDirPrefixLen);
            $masterFile = $file['master_translation_file'] ?? null;

            $tempFile = $tmpExtract . '/' . $relativePath;

            // --- Missing from ZIP → restore from master ----------------------------
            if (!file_exists($tempFile)) {
                if ($masterFile && file_exists($masterFile)) {
                    $this->ensureDir(dirname($targetPath));
                    copy($masterFile, $targetPath);
                    $this->reportLangFile($fileId, $file, 'restored_missing', [
                        'relative_path' => $relativePath,
                        'reason' => 'File missing from ZIP — restored from master default.',
                    ]);
                } else {
                    $this->reportLangFile($fileId, $file, 'error', [
                        'relative_path' => $relativePath,
                        'reason' => 'File missing from ZIP and no master file available.',
                    ]);
                }
                continue;
            }

            // --- Syntax check ------------------------------------------------------
            $parsed = self::safeIncludePhpFile($tempFile);
            if ($parsed === false) {
                $this->reportLangFile($fileId, $file, 'error', [
                    'relative_path' => $relativePath,
                    'reason' => 'PHP syntax error or file does not return an array. Kept existing file untouched.',
                ]);
                continue;
            }

            // --- Lock keys to master array shape -----------------------------------
            if ($masterFile && file_exists($masterFile)) {
                $masterArray = include $masterFile;
                if (!is_array($masterArray)) {
                    $masterArray = [];
                }

                $extra = count(array_diff_key($parsed, $masterArray));
                $missing = count(array_diff_key($masterArray, $parsed));

                $locked = self::lockKeysToMaster($masterArray, $parsed);
                $this->ensureDir(dirname($targetPath));
                self::writePhpArrayFile($targetPath, $locked);

                $status = ($extra > 0 || $missing > 0) ? 'locked' : 'ok';
                $this->reportLangFile($fileId, $file, $status, [
                    'relative_path' => $relativePath,
                    'extra_keys_dropped' => $extra,
                    'missing_keys_restored' => $missing,
                ]);
            } else {
                // No master → accept as-is after syntax check.
                $this->ensureDir(dirname($targetPath));
                copy($tempFile, $targetPath);
                $this->reportLangFile($fileId, $file, 'ok', [
                    'relative_path' => $relativePath,
                    'reason' => 'No master file; copied as-is after syntax check.',
                ]);
            }
        }
    }

    protected function reportLangFile(string $fileId, array $file, string $status, array $details = []): void
    {
        $this->uploadReport['files'][] = [
            'id' => $fileId,
            'title' => $file['file_title'] ?? $fileId,
            'status' => $status,
            'details' => $details,
        ];

        $this->uploadReport['summary']['total']++;
        $summaryKey = ($status === 'error') ? 'errors' : $status;
        if (isset($this->uploadReport['summary'][$summaryKey])) {
            $this->uploadReport['summary'][$summaryKey]++;
        }
    }

    protected function ensureDir(string $dir): void
    {
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
    }

    /**
     * Safely include a PHP file that is expected to `return <array>`.
     * Catches parse errors and any other throwables.
     *
     * @return array|false  Parsed array on success, false on syntax/parse error or non-array return.
     */
    protected static function safeIncludePhpFile(string $path)
    {
        try {
            $result = (static function ($__p) {
                return include $__p;
            })($path);
        } catch (\Throwable $e) {
            return false;
        }
        return is_array($result) ? $result : false;
    }

    protected static function writePhpArrayFile(string $path, array $data): void
    {
        $content = '<?php return ' . var_export($data, true) . ' ?>';
        \Illuminate\Support\Facades\File::put($path, $content);
    }

    public static function getJapan()
    {
        return self::where('code', '=', 'ja')->first();
    }

    public static function getByCode($code)
    {
        return self::where('code', '=', $code)->first();
    }

    public static function getDefaultLanguage()
    {
        return self::getByCode(config('app.locale'));
    }
}