Files
nexusphp/app/Repositories/ToolRepository.php
2025-10-29 15:28:52 +07:00

586 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Repositories;
use App\Http\Middleware\Locale;
use App\Models\Invite;
use App\Models\Message;
use App\Models\News;
use App\Models\Poll;
use App\Models\PollAnswer;
use App\Models\Setting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Nexus\Database\NexusDB;
use Nexus\Plugin\Plugin;
use NexusPlugin\Permission\PermissionRepository;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
class ToolRepository extends BaseRepository
{
const BACKUP_EXCLUDES = ['vendor', 'node_modules', '.git', '.idea', '.settings', '.DS_Store', '.github'];
const BACKUP_RETENTION_COUNT_DEFAULT = 10;
public function backupWeb($method = null, $transfer = false): array
{
$webRoot = base_path();
$dirName = basename($webRoot);
$excludes = self::BACKUP_EXCLUDES;
$baseFilename = sprintf('%s/%s.web.%s', $this->getBackupExportPath(), $dirName, date('Ymd.His'));
if (command_exists('tar') && ($method === 'tar' || $method === null)) {
$filename = $baseFilename . ".tar.gz";
$command = "tar";
foreach ($excludes as $item) {
$command .= " --exclude=$dirName/$item";
}
$command .= sprintf(
' -czf %s -C %s %s 2>&1',
$filename, dirname($webRoot), $dirName
);
$result = exec($command, $output, $result_code);
do_log(sprintf(
"command: %s, output: %s, result_code: %s, result: %s, filename: %s",
$command, json_encode($output), $result_code, $result, $filename
));
} else {
//use php zip
$filename = $baseFilename . ".zip";
$zip = new \ZipArchive();
$zipOpen = $zip->open($filename, \ZipArchive::CREATE);
if ($zipOpen !== true) {
throw new \RuntimeException("Can not open $filename, error: $zipOpen");
}
// create recursive directory iterator
$files = new \RecursiveIteratorIterator (new \RecursiveDirectoryIterator($webRoot, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY);
// let's iterate
foreach ($files as $name => $file) {
$localeName = substr($name, strlen($webRoot) + 1);
$start = strstr($localeName, DIRECTORY_SEPARATOR, true) ?: $localeName;
//add a directory
$localeName = $dirName . DIRECTORY_SEPARATOR . $localeName;
if (!in_array($start, $excludes)) {
if (is_file($name)) {
$zip->addFile($name, $localeName);
} elseif (is_dir($name)) {
do_log("Is dir: $name.");
$zip->addEmptyDir($localeName);
} else {
do_log("Not file or dir $name.", 'error');
}
}
}
$zip->close();
$result_code = 0;
do_log("No tar command, use zip.");
}
if (!$transfer) {
return compact('result_code', 'filename');
}
return $this->transfer($filename, $result_code);
}
public function backupDatabase($transfer = false): array
{
$connectionName = config('database.default');
$config = config("database.connections.$connectionName");
$filename = sprintf('%s/%s.database.%s.sql', $this->getBackupExportPath(), basename(base_path()), date('Ymd.His'));
if (command_exists("mariadb-dump")) {
$command = sprintf(
'mariadb-dump --user=%s --password=%s --host=%s --port=%s --single-transaction --no-create-db --no-tablespaces --ssl=0 %s >> %s 2>&1',
$config['username'], $config['password'], $config['host'], $config['port'], $config['database'], $filename,
);
} else {
$command = sprintf(
'mysqldump --user=%s --password=%s --host=%s --port=%s --single-transaction --no-create-db --no-tablespaces --ssl-mode=DISABLED %s >> %s 2>&1',
$config['username'], $config['password'], $config['host'], $config['port'], $config['database'], $filename,
);
}
$result = exec($command, $output, $result_code);
do_log(sprintf(
"command: %s, output: %s, result_code: %s, result: %s, filename: %s",
$command, json_encode($output), $result_code, $result, $filename
));
if (!$transfer) {
return compact('result_code', 'filename');
}
return $this->transfer($filename, $result_code);
}
public function backupAll($method = null, $transfer = false): array
{
$backupWeb = $this->backupWeb($method);
if ($backupWeb['result_code'] != 0) {
throw new \RuntimeException("backup web fail: " . json_encode($backupWeb));
}
$backupDatabase = $this->backupDatabase();
if ($backupDatabase['result_code'] != 0) {
throw new \RuntimeException("backup database fail: " . json_encode($backupDatabase));
}
$baseFilename = sprintf('%s/%s.%s', $this->getBackupExportPath(), basename(base_path()), date('Ymd.His'));
if (command_exists('tar') && ($method === 'tar' || $method === null)) {
$filename = $baseFilename . ".tar.gz";
$command = sprintf(
'tar -czf %s -C %s %s -C %s %s 2>&1',
$filename,
dirname($backupWeb['filename']), basename($backupWeb['filename']),
dirname($backupDatabase['filename']), basename($backupDatabase['filename'])
);
$result = exec($command, $output, $result_code);
do_log(sprintf(
"command: %s, output: %s, result_code: %s, result: %s, filename: %s",
$command, json_encode($output), $result_code, $result, $filename
));
} else {
//use php zip
$filename = $baseFilename . ".zip";
$zip = new \ZipArchive();
$zipOpen = $zip->open($filename, \ZipArchive::CREATE);
if ($zipOpen !== true) {
throw new \RuntimeException("Can not open $filename, error: $zipOpen");
}
$zip->addFile($backupWeb['filename'], basename($backupWeb['filename']));
$zip->addFile($backupDatabase['filename'], basename($backupDatabase['filename']));
$zip->close();
$result_code = 0;
do_log("No tar command, use zip.");
}
File::delete($backupWeb['filename']);
File::delete($backupDatabase['filename']);
if (!$transfer) {
return compact('result_code', 'filename');
}
return $this->transfer($filename, $result_code);
}
private function getBackupExportPath(): string
{
$path = Setting::getBackupExportPath();
if (empty($path)) {
$path = self::getBackupExportPathDefault();
}
return $path;
}
public static function getBackupExportPathDefault(): string
{
return sys_get_temp_dir() . "/nexusphp_backup";
}
/**
* do backup cronjob
*
* @return array|false
*/
public function cronjobBackup($force = false): bool|array
{
$setting = Setting::get('backup');
if ($setting['enabled'] != 'yes' && !$force) {
do_log("Backup not enabled.");
return false;
}
$now = now();
$frequency = $setting['frequency'];
$settingHour = (int)$setting['hour'];
$settingMinute = (int)$setting['minute'];
$nowHour = (int)$now->format('H');
$nowMinute = (int)$now->format('i');
do_log("Backup frequency: $frequency, force: " . strval($force));
if (!$force) {
if ($frequency == 'daily') {
if ($settingHour != $nowHour) {
do_log(sprintf('Backup setting hour: %s != now hour: %s', $settingHour, $nowHour));
return false;
}
if ($settingMinute != $nowMinute) {
do_log(sprintf('Backup setting minute: %s != now minute: %s', $settingMinute, $nowMinute));
return false;
}
} elseif ($frequency == 'hourly') {
if ($settingMinute != $nowMinute) {
do_log(sprintf('Backup setting minute: %s != now minute: %s', $settingMinute, $nowMinute));
return false;
}
} else {
throw new \RuntimeException("Unknown backup frequency: $frequency");
}
}
$backupResult = $this->backupAll();
do_log("Backup all result: " . json_encode($backupResult));
$transferResult = $this->transfer($backupResult['filename'], $backupResult['result_code'], $setting);
$backupResult['transfer_result'] = $transferResult;
do_log("[BACKUP_ALL_DONE]: " . json_encode($backupResult));
$this->cleanupBackupFiles(basename($backupResult['filename']));
return $backupResult;
}
public function transfer($filename, $result_code, $setting = null): array
{
if ($result_code != 0) {
throw new \RuntimeException("file: $filename backup fail!");
}
$result = compact('filename', 'result_code');
if (empty($setting)) {
$setting = Setting::get('backup');
}
$saveResult = $this->saveToGoogleDrive($setting, $filename);
do_log("[BACKUP_GOOGLE_DRIVE]: $saveResult");
$result['google_drive'] = $saveResult;
$saveResult = $this->saveToFtp($setting, $filename);
do_log("[BACKUP_FTP]: $saveResult");
$result['ftp'] = $saveResult;
$saveResult = $this->saveToSftp($setting, $filename);
do_log("[BACKUP_SFTP]: $saveResult");
$result['sftp'] = $saveResult;
return $result;
}
private function saveToGoogleDrive(array $setting, $filename): bool|string
{
$clientId = $setting['google_drive_client_id'] ?? '';
$clientSecret = $setting['google_drive_client_secret'] ?? '';
$refreshToken = $setting['google_drive_refresh_token'] ?? '';
$folderId = $setting['google_drive_folder_id'] ?? '';
if (empty($clientId)) {
do_log("No google_drive_client_id, won't do upload.");
return false;
}
if (empty($clientSecret)) {
do_log("No google_drive_client_secret, won't do upload.");
return false;
}
if (empty($refreshToken)) {
do_log("No google_drive_refresh_token, won't do upload.");
return false;
}
do_log("Google drive info: clientId: $clientId, clientSecret: $clientSecret, refreshToken: $refreshToken, folderId: $folderId");
$client = new \Google\Client();
$client->setClientId($clientId);
$client->setClientSecret($clientSecret);
$client->refreshToken($refreshToken);
$service = new \Google\Service\Drive($client);
$adapter = new \Masbug\Flysystem\GoogleDriveAdapter($service, $folderId);
$filesystem = new \League\Flysystem\Filesystem($adapter);
$disk = new \Illuminate\Filesystem\FilesystemAdapter($filesystem, $adapter);
return $this->doTransfer($disk, $filename);
}
private function saveToFtp(array $setting, $filename): bool|string
{
if ($setting['via_ftp'] !== 'yes') {
do_log("via_ftp !== 'yes', via_ftp: " . $setting['via_ftp'] ?? '');
return false;
}
$config = config('filesystems.disks.ftp');
if (empty($config)) {
do_log("No ftp config.");
return false;
}
foreach (['host', 'username', 'password', 'root'] as $item) {
if (empty($config[$item])) {
do_log("No ftp $item.");
return false;
}
}
$disk = Storage::disk('ftp');
return $this->doTransfer($disk, $filename);
}
public function saveToSftp(array $setting, $filename): bool|string
{
if ($setting['via_sftp'] !== 'yes') {
do_log("via_sftp !== 'yes', via_sftp: " . $setting['via_sftp'] ?? '');
return false;
}
$config = config('filesystems.disks.sftp');
if (empty($config)) {
do_log("No sftp config.");
return false;
}
foreach (['host', 'username', 'password', 'root'] as $item) {
if (empty($config[$item])) {
do_log("No sftp $item.");
return false;
}
}
$disk = Storage::disk('sftp');
return $this->doTransfer($disk, $filename);
}
private function doTransfer(\Illuminate\Filesystem\FilesystemAdapter $remoteFilesystem, $filename): bool|string
{
$localAdapter = new \League\Flysystem\Local\LocalFilesystemAdapter('/');
$localFilesystem = new \League\Flysystem\Filesystem($localAdapter);
$start = Carbon::now();
try {
$remoteFilesystem->writeStream(basename($filename), $localFilesystem->readStream($filename));
$speed = !(float)abs($start->diffInSeconds()) ? 0 :filesize($filename) / (float)abs($start->diffInSeconds());
$log = 'Elapsed time: '.$start->diffForHumans(null, true);
$log .= ', Speed: '. number_format($speed/1024,2) . ' KB/s';
do_log($log);
return true;
} catch (\Throwable $exception) {
do_log("Transfer error: " . $exception->getMessage(), 'error');
return $exception->getMessage();
}
}
private function cleanupBackupFiles($basename): void
{
$nameParts = explode('.', $basename);
$firstPart = $nameParts[0];
$lastPart = $nameParts[count($nameParts) - 1];
$retentionCount = Setting::getBackupRetentionCount();
if ($retentionCount <= 0) {
$retentionCount = self::BACKUP_RETENTION_COUNT_DEFAULT;
}
$path = self::getBackupExportPath();
$allFiles = collect(File::allFiles($path))->filter(function (\Symfony\Component\Finder\SplFileInfo $file) use ($firstPart, $lastPart) {
$name = basename($file->getRealPath());
return str_starts_with($name, $firstPart) && str_ends_with($name, $lastPart);
});
// 按创建时间降序排序
$allFiles = $allFiles->sortByDesc(fn (\Symfony\Component\Finder\SplFileInfo $file) => $file->getCTime());
$filesToDelete = $allFiles->slice($retentionCount);
do_log(sprintf(
"retentionCount: %s, path: %s, fileCount: %s",
$retentionCount, $path, $allFiles->count()
));
foreach ($filesToDelete as $file) {
$realPath = $file->getRealPath();
File::delete($realPath);
do_log(sprintf("delete backup file: %s", $realPath));
}
}
/**
* @param $to
* @param $subject
* @param $body
* @return bool
*/
public function sendMail($to, $subject, $body, $exception = false): bool
{
$log = "[SEND_MAIL]";
$factory = new EsmtpTransportFactory();
$smtp = Setting::getFromDb('smtp');
do_log("$log, to: $to, subject: $subject, body: $body, smtp: " . json_encode($smtp));
$encryption = null;
if (isset($smtp['encryption']) && in_array($smtp['encryption'], ['ssl', 'tls'])) {
$encryption = $smtp['encryption'];
}
// Create the Transport
$transport = $factory->create(new Dsn(
// $encryption === 'tls' ? (($smtp['smtpport'] == 465) ? 'smtps' : 'smtp') : '',
$smtp['smtpport'] == 465 && in_array($encryption, ['ssl', 'tls']) ? 'smtps' : 'smtp',
$smtp['smtpaddress'],
$smtp['accountname'] ?? null,
$smtp['accountpassword'] ?? null,
$smtp['smtpport'] ?? null,
['verify_peer' => false]
));
// Create the Mailer using your created Transport
$mailer = new Mailer($transport);
// Create a message
$message = (new Email())
->from(new Address(Setting::get('main.SITEEMAIL'), Setting::get('basic.SITENAME')))
->to($to)
->subject($subject)
->text($body)
->html(nl2br($body))
;
// Send the message
try {
$mailer->send($message);
return true;
} catch (\Throwable $e) {
do_log("$log, fail: " . $e->getMessage() . "\n" . $e->getTraceAsString(), 'error');
if ($exception) {
throw $e;
} else {
return false;
}
}
}
public function getNotificationCount(User $user): array
{
$result = [];
//attend or not
$attendRep = new AttendanceRepository();
$attendance = $attendRep->getAttendance($user->id, date('Ymd'));
$result['attendance'] = $attendance ? 0 : 1;
//unread news
$count = News::query()->where('added', '>', $user->last_home)->count();
$result['news'] = $count;
//unread messages
$count = Message::query()->where('receiver', $user->id)->where('unread', 'yes')->count();
$result['message'] = $count;
//un-vote poll
$total = Poll::query()->count();
$userVoteCount = PollAnswer::query()->where('userid', $user->id)->selectRaw('count(distinct(pollid)) as counts')->first()->counts;
$result['poll'] = $total - $userVoteCount;
return $result;
}
public static function listUserClassPermissions($class): array
{
$settings = Setting::get('authority');
$result = [];
foreach ($settings as $permission => $minClass) {
if ($minClass >= User::CLASS_PEASANT && $minClass <= $class) {
$result[] = $permission;
}
}
return $result;
}
public static function listUserAllPermissions($uid): array
{
static $uidPermissionsCached = [];
if (isset($uidPermissionsCached[$uid])) {
return $uidPermissionsCached[$uid];
}
$log = "uid: $uid";
$userInfo = get_user_row($uid);
$class = $userInfo['class'];
//Class permission
$classPermissions = self::listUserClassPermissions($class);
//Role permission
$rolePermissions = apply_filter("user_role_permissions", [], $uid);
//Direct permission
$directPermissions = apply_filter("user_direct_permissions", [], $uid);
$allPermissions = array_merge($classPermissions, $rolePermissions, $directPermissions);
do_log("$log, allPermissions: " . json_encode($allPermissions));
$result = array_combine($allPermissions, $allPermissions);
$uidPermissionsCached[$uid] = $result;
return $result;
}
public function generateUniqueInviteHash(array $hashArr, int $total, int $left, int $deep = 0): array
{
do_log("total: $total, left: $left, deep: $deep");
if ($deep > 10) {
throw new \RuntimeException("deep: $deep > 10");
}
if (count($hashArr) >= $total) {
return array_slice(array_values($hashArr), 0, $total);
}
for ($i = 0; $i < $left; $i++) {
$hash = Str::random(32);
$hashArr[$hash] = $hash;
}
$exists = Invite::query()->whereIn('hash', array_values($hashArr))->get(['id', 'hash']);
foreach($exists as $value) {
unset($hashArr[$value->hash]);
}
return $this->generateUniqueInviteHash($hashArr, $total, $total - count($hashArr), ++$deep);
}
public function removeDuplicateSnatch()
{
$size = 2000;
$stickyPromotionParticipatorsTable = 'sticky_promotion_participators';
$claimTable = "claims";
$hitAndRunTable = "hit_and_runs";
$stickyPromotionExists = NexusDB::hasTable($stickyPromotionParticipatorsTable);
$claimTableExists = NexusDB::hasTable($claimTable);
$hitAndRunTableExists = NexusDB::hasTable($hitAndRunTable);
while (true) {
$snatchRes = NexusDB::select("select userid, torrentid, group_concat(id) as ids from snatched group by userid, torrentid having(count(*)) > 1 limit $size");
if (empty($snatchRes)) {
break;
}
do_log("[DELETE_DUPLICATED_SNATCH], count: " . count($snatchRes));
foreach ($snatchRes as $snatchRow) {
$torrentId = $snatchRow['torrentid'];
$userId = $snatchRow['userid'];
$idArr = explode(',', $snatchRow['ids']);
sort($idArr, SORT_NUMERIC);
$remainId = array_pop($idArr);
$delIdStr = implode(',', $idArr);
do_log("[DELETE_DUPLICATED_SNATCH], torrent: $torrentId, user: $userId, snatchIdStr: $delIdStr");
NexusDB::statement("delete from snatched where id in ($delIdStr)");
if ($claimTableExists) {
NexusDB::statement("update $claimTable set snatched_id = $remainId where torrent_id = $torrentId and uid = $userId");
}
if ($hitAndRunTableExists) {
NexusDB::statement("update $hitAndRunTable set snatched_id = $remainId where torrent_id = $torrentId and uid = $userId");
}
if ($stickyPromotionExists) {
NexusDB::statement("update $stickyPromotionParticipatorsTable set snatched_id = $remainId where torrent_id = $torrentId and uid = $userId");
}
}
}
}
public function removeDuplicatePeer()
{
$size = 2000;
while (true) {
$results = NexusDB::select("select torrent, userid, group_concat(id) as ids from peers group by torrent, peer_id, userid having(count(*)) > 1 limit $size");
if (empty($results)) {
do_log("[DELETE_DUPLICATED_PEERS], no data: ". last_query());
break;
}
do_log("[DELETE_DUPLICATED_PEERS], count: " . count($results));
foreach ($results as $row) {
$torrentId = $row['torrent'];
$userId = $row['userid'];
$idArr = explode(',', $row['ids']);
sort($idArr, SORT_NUMERIC);
$remainId = array_pop($idArr);
$delIdStr = implode(',', $idArr);
do_log("[DELETE_DUPLICATED_PEERS], torrent: $torrentId, user: $userId, snatchIdStr: $delIdStr");
NexusDB::statement("delete from peers where id in ($delIdStr)");
}
}
}
public function sendAlarmEmail(string $subjectTransKey, array $subjectTransContext, string $msgTransKey, array $msgTransContext): void
{
$receiverUid = get_setting("system.alarm_email_receiver");
if (empty($receiverUid)) {
$locale = Locale::getDefault();
$subject = nexus_trans($subjectTransKey, $subjectTransContext, $locale);
$msg = nexus_trans($msgTransKey, $msgTransContext, $locale);
do_log(sprintf("%s - %s", $subject, $msg), "error");
} else {
$receiverUidArr = preg_split("/[\r\n\s,]+/", $receiverUid);
$users = User::query()->whereIn("id", $receiverUidArr)->get(User::$commonFields);
foreach ($users as $user) {
$locale = $user->locale;
$subject = nexus_trans($subjectTransKey, $subjectTransContext, $locale);
$msg = nexus_trans($msgTransKey, $msgTransContext, $locale);
$result = $this->sendMail($user->email, $subject, $msg);
do_log(sprintf("send msg: %s result: %s", $msg, var_export($result, true)), $result ? "info" : "error");
}
}
}
}