mirror of
git://f0xx.org/ac/ac-ms-build
synced 2026-07-29 00:59:05 +03:00
feat(build): error sanitizer and failed-build notifier
Signed-off-by: Anton Afanasyeu <a.afanasieff@gmail.com>
This commit is contained in:
28
src/BuildErrorSanitizer.php
Normal file
28
src/BuildErrorSanitizer.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Strip URLs, paths with secrets, and credential-like tokens from user-visible build errors. */
|
||||
final class BuildErrorSanitizer {
|
||||
public static function sanitize(string $message): string {
|
||||
if ($message === '') {
|
||||
return '';
|
||||
}
|
||||
$out = $message;
|
||||
$out = preg_replace('#https?://[^\s\'"<>]+#i', '***', $out) ?? $out;
|
||||
$out = preg_replace('#git://[^\s\'"<>]+#i', '***', $out) ?? $out;
|
||||
$out = preg_replace('#ssh://[^\s\'"<>]+#i', '***', $out) ?? $out;
|
||||
$out = preg_replace(
|
||||
'/\b(password|passwd|pass|token|secret|api[_-]?key|bearer|authorization)\s*[:=]\s*\S+/i',
|
||||
'$1=***',
|
||||
$out
|
||||
) ?? $out;
|
||||
$out = preg_replace(
|
||||
'/(?:\/[^\s]+)?\/(?:\.env|credentials(?:\.json)?|secrets?\.(?:json|ya?ml|env))[^\s]*/i',
|
||||
'***',
|
||||
$out
|
||||
) ?? $out;
|
||||
$out = preg_replace('#/var/www/[^\s\'"]+#', '***', $out) ?? $out;
|
||||
$out = preg_replace('#/home/[^\s\'"]+#', '***', $out) ?? $out;
|
||||
return trim(preg_replace('/\s{2,}/', ' ', $out) ?? $out);
|
||||
}
|
||||
}
|
||||
236
src/BuildNotifier.php
Normal file
236
src/BuildNotifier.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Failed-build alerts via email and Telegram (admin + optional user channel). */
|
||||
final class BuildNotifier {
|
||||
/** @param array<string, mixed> $build */
|
||||
public static function onFailure(int $buildId, array $build, string $errorMessage): void {
|
||||
$params = json_decode((string) ($build['params_json'] ?? '{}'), true);
|
||||
if (!is_array($params)) {
|
||||
$params = [];
|
||||
}
|
||||
$trigger = (string) ($params['trigger_source'] ?? 'manual');
|
||||
$safeError = BuildErrorSanitizer::sanitize($errorMessage);
|
||||
$code = (string) ($build['build_code'] ?? ('#' . $buildId));
|
||||
$ref = (string) ($build['git_ref'] ?? $build['branch'] ?? '—');
|
||||
$basePath = rtrim((string) cfg('base_path', '/app/androidcast_project/build'), '/');
|
||||
$detailUrl = self::publicBaseUrl() . $basePath . '/?view=build&id=' . $buildId;
|
||||
$subject = 'Android Cast build failed: ' . $code;
|
||||
$bodyText = "Build {$code} failed (ref: {$ref}).\n\n"
|
||||
. $safeError . "\n\n"
|
||||
. "Details: {$detailUrl}\n";
|
||||
|
||||
if (in_array($trigger, ['autobuild', 'agent', 'ci'], true)) {
|
||||
self::notifyAdmin($subject, $bodyText, $code, $safeError, $detailUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($params['notify_on_fail'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$channel = (string) ($params['notify_channel'] ?? 'email');
|
||||
$userId = (int) ($build['created_by_user_id'] ?? 0);
|
||||
$username = self::usernameFor($userId);
|
||||
$userPrefix = $username !== '' ? "User {$username}: " : '';
|
||||
|
||||
if ($channel === 'telegram' || $channel === 'both') {
|
||||
self::sendTelegram(
|
||||
$userPrefix . $subject,
|
||||
$bodyText,
|
||||
self::telegramConfig()
|
||||
);
|
||||
}
|
||||
if ($channel === 'email' || $channel === 'both') {
|
||||
$to = self::emailForUser($userId);
|
||||
if ($to !== '') {
|
||||
self::sendPlainEmail($to, $subject, nl2br(h($bodyText)), self::smtpConfig());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function notifyAdmin(string $subject, string $bodyText, string $code, string $safeError, string $detailUrl): void {
|
||||
$tg = self::telegramConfig();
|
||||
if ($tg['token'] !== '' && $tg['chat_id'] !== '') {
|
||||
self::sendTelegram($subject, $bodyText, $tg);
|
||||
}
|
||||
$admin = (string) cfg('build.notify.admin_email', 'bestcastr@gmail.com');
|
||||
if ($admin !== '') {
|
||||
$html = '<p><strong>' . h($code) . '</strong> failed.</p>'
|
||||
. '<pre style="white-space:pre-wrap">' . h($safeError) . '</pre>'
|
||||
. '<p><a href="' . h($detailUrl) . '">Open build log</a></p>';
|
||||
self::sendPlainEmail($admin, $subject, $html, self::smtpConfig());
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{token:string,chat_id:string} */
|
||||
private static function telegramConfig(): array {
|
||||
$token = trim((string) cfg('build.notify.telegram_token', ''));
|
||||
$chatId = trim((string) cfg('build.notify.telegram_chat_id', ''));
|
||||
if ($token === '' || $chatId === '') {
|
||||
$bc = self::loadBroadcastConfig();
|
||||
$token = $token !== '' ? $token : trim((string) ($bc['telegram_token'] ?? ''));
|
||||
$chatId = $chatId !== '' ? $chatId : trim((string) ($bc['telegram_chat_id'] ?? ''));
|
||||
}
|
||||
return ['token' => $token, 'chat_id' => $chatId];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function smtpConfig(): array {
|
||||
$mail = cfg('mail', []);
|
||||
if (is_array($mail) && !empty($mail['smtp'])) {
|
||||
$smtp = $mail['smtp'];
|
||||
return [
|
||||
'host' => (string) ($smtp['host'] ?? 'smtp.gmail.com'),
|
||||
'port' => (int) ($smtp['port'] ?? 587),
|
||||
'user' => (string) ($smtp['user'] ?? $smtp['username'] ?? ''),
|
||||
'pass' => (string) ($smtp['password'] ?? $smtp['pass'] ?? ''),
|
||||
'from' => (string) ($mail['from'] ?? $smtp['user'] ?? ''),
|
||||
'from_name' => (string) ($mail['from_name'] ?? 'AndroidCast Builder'),
|
||||
];
|
||||
}
|
||||
$bc = self::loadBroadcastConfig();
|
||||
return [
|
||||
'host' => (string) ($bc['smtp_host'] ?? 'smtp.gmail.com'),
|
||||
'port' => (int) ($bc['smtp_port'] ?? 587),
|
||||
'user' => (string) ($bc['smtp_user'] ?? ''),
|
||||
'pass' => (string) ($bc['smtp_password'] ?? ''),
|
||||
'from' => (string) ($bc['smtp_from'] ?? $bc['smtp_user'] ?? ''),
|
||||
'from_name' => (string) ($bc['smtp_from_name'] ?? 'AndroidCast Builder'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function loadBroadcastConfig(): array {
|
||||
static $cached = null;
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
$path = (string) cfg(
|
||||
'build.notify.broadcast_config',
|
||||
'/var/www/ac/broadcast/config/config.php'
|
||||
);
|
||||
if ($path !== '' && is_file($path)) {
|
||||
$cfg = require $path;
|
||||
$cached = is_array($cfg) ? $cfg : [];
|
||||
return $cached;
|
||||
}
|
||||
$cached = [];
|
||||
return $cached;
|
||||
}
|
||||
|
||||
private static function publicBaseUrl(): string {
|
||||
$host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? 'apps.f0xx.org';
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|
||||
? 'https' : 'http';
|
||||
return $scheme . '://' . explode(',', (string) $host)[0];
|
||||
}
|
||||
|
||||
private static function emailForUser(int $userId): string {
|
||||
if ($userId <= 0) {
|
||||
return '';
|
||||
}
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT email_normalized, username FROM users WHERE id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return '';
|
||||
}
|
||||
$email = trim((string) ($row['email_normalized'] ?? ''));
|
||||
if ($email !== '' && str_contains($email, '@')) {
|
||||
return $email;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function usernameFor(int $userId): string {
|
||||
if ($userId <= 0) {
|
||||
return '';
|
||||
}
|
||||
$stmt = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ? (string) ($row['username'] ?? '') : '';
|
||||
}
|
||||
|
||||
/** @param array{token:string,chat_id:string} $tg */
|
||||
private static function sendTelegram(string $title, string $body, array $tg): void {
|
||||
if ($tg['token'] === '' || $tg['chat_id'] === '') {
|
||||
return;
|
||||
}
|
||||
$text = '<b>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</b>\n\n"
|
||||
. htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$payload = http_build_query([
|
||||
'chat_id' => $tg['chat_id'],
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML',
|
||||
'disable_web_page_preview' => '1',
|
||||
]);
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
|
||||
'content' => $payload,
|
||||
'timeout' => 15,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
@file_get_contents(
|
||||
'https://api.telegram.org/bot' . $tg['token'] . '/sendMessage',
|
||||
false,
|
||||
$ctx
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $smtp */
|
||||
private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void {
|
||||
if ($to === '' || empty($smtp['user']) || empty($smtp['pass'])) {
|
||||
return;
|
||||
}
|
||||
$host = (string) ($smtp['host'] ?? 'smtp.gmail.com');
|
||||
$port = (int) ($smtp['port'] ?? 587);
|
||||
$from = (string) ($smtp['from'] ?? $smtp['user']);
|
||||
$fromName = (string) ($smtp['from_name'] ?? 'AndroidCast Builder');
|
||||
$boundary = bin2hex(random_bytes(8));
|
||||
$headers = [
|
||||
"From: {$fromName} <{$from}>",
|
||||
"To: {$to}",
|
||||
'Subject: =?UTF-8?B?' . base64_encode($subject) . '?=',
|
||||
'MIME-Version: 1.0',
|
||||
"Content-Type: multipart/alternative; boundary=\"{$boundary}\"",
|
||||
];
|
||||
$plain = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $htmlBody));
|
||||
$body = "--{$boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{$plain}\r\n"
|
||||
. "--{$boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{$htmlBody}\r\n"
|
||||
. "--{$boundary}--";
|
||||
$sock = @fsockopen("tls://{$host}", $port, $errno, $errstr, 15);
|
||||
if (!$sock) {
|
||||
return;
|
||||
}
|
||||
$read = static fn(): string => (string) fgets($sock, 1024);
|
||||
$send = static function (string $cmd) use ($sock): void {
|
||||
fwrite($sock, $cmd . "\r\n");
|
||||
};
|
||||
$read();
|
||||
$send('EHLO builder.local');
|
||||
$read();
|
||||
$send('AUTH LOGIN');
|
||||
$read();
|
||||
$send(base64_encode((string) $smtp['user']));
|
||||
$read();
|
||||
$send(base64_encode((string) $smtp['pass']));
|
||||
$read();
|
||||
$send("MAIL FROM:<{$from}>");
|
||||
$read();
|
||||
$send("RCPT TO:<{$to}>");
|
||||
$read();
|
||||
$send('DATA');
|
||||
$read();
|
||||
fwrite($sock, implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.\r\n");
|
||||
$send('QUIT');
|
||||
fclose($sock);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,9 @@ YAML;
|
||||
|
||||
public static function enqueue(array $params, ?int $userId): array {
|
||||
BuildRepository::ensureSchema();
|
||||
if (!isset($params['trigger_source'])) {
|
||||
$params['trigger_source'] = $userId !== null ? 'manual' : 'agent';
|
||||
}
|
||||
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
|
||||
$buildCode = self::generateBuildCode();
|
||||
$id = BuildRepository::create([
|
||||
@@ -138,6 +141,8 @@ YAML;
|
||||
. ' -C ' . escapeshellarg($repo)
|
||||
. ' remote get-url origin 2>/dev/null'
|
||||
));
|
||||
$mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
|
||||
$scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
|
||||
$env = [
|
||||
'BUILD_ID=' . $id,
|
||||
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
|
||||
@@ -146,6 +151,9 @@ YAML;
|
||||
'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)),
|
||||
'BUILD_WORK_DIR=' . escapeshellarg($repo),
|
||||
'BUILD_OUT_DIR=' . escapeshellarg($dir),
|
||||
'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub),
|
||||
'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)),
|
||||
'BUILD_DOCKERFILE=' . escapeshellarg((string) cfg('build.dockerfile', '')),
|
||||
'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')),
|
||||
'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')),
|
||||
'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
|
||||
@@ -185,15 +193,20 @@ YAML;
|
||||
}
|
||||
|
||||
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
|
||||
$safe = BuildErrorSanitizer::sanitize($message);
|
||||
if ($logPath) {
|
||||
file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
|
||||
file_put_contents($logPath, "\n[builder] ERROR: {$safe}\n", FILE_APPEND);
|
||||
}
|
||||
BuildRepository::update($id, [
|
||||
'status' => 'failed',
|
||||
'phase' => 'error',
|
||||
'error_message' => substr($message, 0, 512),
|
||||
'error_message' => substr($safe, 0, 512),
|
||||
'finished_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
$build = BuildRepository::getById($id);
|
||||
if ($build) {
|
||||
BuildNotifier::onFailure($id, $build, $safe);
|
||||
}
|
||||
}
|
||||
|
||||
public static function stopBuild(int $id): bool {
|
||||
@@ -276,6 +289,14 @@ YAML;
|
||||
|
||||
/** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
|
||||
public static function detectFailedStep(int $buildId): string {
|
||||
$build = BuildRepository::getById($buildId);
|
||||
if ($build) {
|
||||
$err = (string) ($build['error_message'] ?? '');
|
||||
$started = (string) ($build['started_at'] ?? '');
|
||||
if ($started === '' && str_contains($err, 'Docker daemon not available')) {
|
||||
return 'docker-setup';
|
||||
}
|
||||
}
|
||||
$dir = self::artifactDir($buildId);
|
||||
$logTail = '';
|
||||
$logPath = $dir . '/build.log';
|
||||
@@ -456,7 +477,7 @@ YAML;
|
||||
if (strlen($summary) > 480) {
|
||||
$summary = '…' . substr($summary, -477);
|
||||
}
|
||||
return 'Build runner exited unexpectedly: ' . $summary;
|
||||
return BuildErrorSanitizer::sanitize('Build runner exited unexpectedly: ' . $summary);
|
||||
}
|
||||
|
||||
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
|
||||
@@ -508,13 +529,27 @@ YAML;
|
||||
$versionApp = $info['gitSha'] ?? null;
|
||||
}
|
||||
}
|
||||
BuildRepository::update($buildId, [
|
||||
$update = [
|
||||
'status' => $status,
|
||||
'phase' => 'done',
|
||||
'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES),
|
||||
'version_app' => $versionApp,
|
||||
'finished_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
];
|
||||
if ($status === 'failed') {
|
||||
$update['error_message'] = substr(
|
||||
BuildErrorSanitizer::sanitize('Build finished without APK artifact'),
|
||||
0,
|
||||
512
|
||||
);
|
||||
}
|
||||
BuildRepository::update($buildId, $update);
|
||||
if ($status === 'failed') {
|
||||
$build = BuildRepository::getById($buildId);
|
||||
if ($build) {
|
||||
BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
|
||||
}
|
||||
}
|
||||
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
|
||||
$build = BuildRepository::getById($buildId);
|
||||
if (!empty($build['auto_deploy'])) {
|
||||
@@ -533,19 +568,86 @@ YAML;
|
||||
return;
|
||||
}
|
||||
$build = BuildRepository::getById($buildId);
|
||||
$channel = $build['ota_channel'] ?? 'staging';
|
||||
$channel = (string) ($build['ota_channel'] ?? 'staging');
|
||||
$dest = $mount . '/v0';
|
||||
shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/'));
|
||||
if ($channel !== '' && $channel !== 'staging') {
|
||||
$channelJson = $src . '/ota/channel/' . $channel . '.json';
|
||||
if (is_file($channelJson)) {
|
||||
shell_exec(
|
||||
'cp -a '
|
||||
. escapeshellarg($channelJson)
|
||||
. ' '
|
||||
. escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
|
||||
);
|
||||
$channelJson = $src . '/ota/channel/' . $channel . '.json';
|
||||
if (is_file($channelJson)) {
|
||||
shell_exec(
|
||||
'mkdir -p '
|
||||
. escapeshellarg($dest . '/ota/channel')
|
||||
. ' && cp -a '
|
||||
. escapeshellarg($channelJson)
|
||||
. ' '
|
||||
. escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
|
||||
);
|
||||
}
|
||||
self::publishBrowserApk($src, $channel);
|
||||
}
|
||||
|
||||
/** Copy installable .apk to hub /v0/downloads/ for browser sideload. */
|
||||
private static function publishBrowserApk(string $otaV0Dir, string $channel): void {
|
||||
$downloads = rtrim((string) cfg('build.downloads_mount', ''), '/');
|
||||
if ($downloads === '') {
|
||||
$parent = dirname(rtrim((string) cfg('build.ota_mount', ''), '/'));
|
||||
$downloads = $parent !== '' && $parent !== '.'
|
||||
? $parent . '/downloads'
|
||||
: '/var/www/localhost/htdocs/apps/app/androidcast_project/downloads';
|
||||
}
|
||||
if (!is_dir($downloads) && !@mkdir($downloads, 0775, true) && !is_dir($downloads)) {
|
||||
return;
|
||||
}
|
||||
$apkPath = self::resolveBrowserApkPath($otaV0Dir, $channel);
|
||||
if ($apkPath === null || !is_file($apkPath)) {
|
||||
return;
|
||||
}
|
||||
$latest = $downloads . '/android_cast-latest.apk';
|
||||
$versioned = $downloads . '/' . basename($apkPath);
|
||||
@copy($apkPath, $latest);
|
||||
@copy($apkPath, $versioned);
|
||||
@chown($latest, 'nginx');
|
||||
@chgrp($latest, 'nginx');
|
||||
@chown($versioned, 'nginx');
|
||||
@chgrp($versioned, 'nginx');
|
||||
}
|
||||
|
||||
private static function resolveBrowserApkPath(string $otaV0Dir, string $channel): ?string {
|
||||
$channelFile = $otaV0Dir . '/ota/channel/' . $channel . '.json';
|
||||
if (is_file($channelFile)) {
|
||||
$channelData = json_decode((string) file_get_contents($channelFile), true);
|
||||
$manifestUrl = is_array($channelData) ? (string) ($channelData['manifestUrl'] ?? '') : '';
|
||||
if ($manifestUrl !== '') {
|
||||
$manifestPath = self::otaUrlToLocalPath($manifestUrl, $otaV0Dir);
|
||||
if ($manifestPath !== null && is_file($manifestPath)) {
|
||||
$manifest = json_decode((string) file_get_contents($manifestPath), true);
|
||||
if (is_array($manifest)) {
|
||||
$browser = (string) ($manifest['browserApkUrl'] ?? '');
|
||||
if ($browser !== '') {
|
||||
$local = self::otaUrlToLocalPath($browser, $otaV0Dir);
|
||||
if ($local !== null && is_file($local)) {
|
||||
return $local;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$glob = glob($otaV0Dir . '/ota/*/*/*/*.apk') ?: [];
|
||||
if ($glob !== []) {
|
||||
usort($glob, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
|
||||
return $glob[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function otaUrlToLocalPath(string $url, string $otaV0Dir): ?string {
|
||||
$path = (string) parse_url($url, PHP_URL_PATH);
|
||||
$prefix = '/v0/ota/';
|
||||
if (!str_starts_with($path, $prefix)) {
|
||||
return null;
|
||||
}
|
||||
$rel = substr($path, strlen($prefix));
|
||||
$local = $otaV0Dir . '/ota/' . $rel;
|
||||
return is_file($local) ? $local : null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user