Keep Tokenview and Telegram running when log files are not writable.

Daily logs created by root cron were blocking www from appending, which aborted webhook ingest and bot pushes. File channels now use 0664 plus exception-safe stacks, and writes go through SafeLog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hashbro
2026-09-15 11:40:44 +08:00
parent 74a85b415b
commit 51336e346a
17 changed files with 432 additions and 37 deletions
@@ -4,9 +4,9 @@ namespace App\Console\Commands;
use App\Models\WalletAddress;
use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\SafeLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class SyncTokenviewMonitorsCommand extends Command
{
@@ -25,7 +25,7 @@ class SyncTokenviewMonitorsCommand extends Command
return $this->printStatus();
}
$log = Log::channel('tokenview');
$log = SafeLog::channel('tokenview');
$startedAt = microtime(true);
$startedAtStr = now()->toDateTimeString();
@@ -200,7 +200,7 @@ class SyncTokenviewMonitorsCommand extends Command
}
}
$logPath = storage_path('logs/tokenview-'.now()->format('Y-m-d').'.log');
$logPath = storage_path('logs/tokenview/tokenview-'.now()->format('Y-m-d').'.log');
$this->line('');
$this->line('today\'s log: '.$logPath);
@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Hooks;
use App\Http\Controllers\Controller;
use App\Support\SafeLog;
use App\Telegram\TelegramBotContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use SergiX44\Nutgram\Nutgram;
use Throwable;
@@ -81,10 +81,6 @@ class TelegramWebhookController extends Controller
/** @param array<string, mixed> $context */
private function webhookLog(string $level, string $message, array $context = []): void
{
try {
Log::channel('telegram')->{$level}($message, $context);
} catch (Throwable) {
error_log($message.' '.json_encode($context, JSON_UNESCAPED_UNICODE));
}
SafeLog::channel('telegram')->{$level}($message, $context);
}
}
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Hooks;
use App\Http\Controllers\Controller;
use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\SafeLog;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Throwable;
class TokenviewWebhookController extends Controller
@@ -73,14 +73,10 @@ class TokenviewWebhookController extends Controller
/** @param array<string, mixed> $context */
private function tvLog(string $level, string $message, array $context = []): void
{
try {
$dir = storage_path('logs/tokenview');
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
Log::channel('tokenview')->{$level}($message, $context);
} catch (Throwable $e) {
Log::warning($message, $context + ['tokenview_channel_error' => $e->getMessage()]);
$dir = storage_path('logs/tokenview');
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
SafeLog::channel('tokenview')->{$level}($message, $context);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ class SendTelegramMessage implements ShouldQueue
{
$result = $telegram->sendToChat($this->chatId, $this->text, $this->token);
if (! $result['ok']) {
\Illuminate\Support\Facades\Log::channel('telegram')->warning('SendTelegramMessage job failed', [
\App\Support\SafeLog::channel('telegram')->warning('SendTelegramMessage job failed', [
'chat_id' => $this->chatId,
'error' => $result['error'] ?? 'unknown',
'text_preview' => mb_substr($this->text, 0, 80),
+4 -3
View File
@@ -8,6 +8,7 @@ use App\Models\Device;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\SafeLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -113,7 +114,7 @@ class TelegramNotifier
{
$chatIds = $this->resolveChatIds($deviceKey);
if ($chatIds === []) {
Log::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [
SafeLog::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [
'device_key' => $deviceKey,
'bot_token_set' => $this->botToken() !== '',
'owner_chat_id' => trim((string) config('coruna.telegram.owner_chat_id', '')),
@@ -173,11 +174,11 @@ class TelegramNotifier
if ($desc === '') {
$desc = 'HTTP '.$resp->status();
}
Log::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
SafeLog::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $desc];
} catch (\Throwable $e) {
Log::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]);
SafeLog::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]);
return ['ok' => false, 'error' => $e->getMessage()];
}
+2 -2
View File
@@ -2,8 +2,8 @@
namespace App\Services\Tokenview;
use App\Support\SafeLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TokenviewClient
{
@@ -24,7 +24,7 @@ class TokenviewClient
private function mutateAddress(string $action, string $coinAbbr, string $address): bool
{
$log = Log::channel('tokenview');
$log = SafeLog::channel('tokenview');
if (! $this->enabled()) {
$log->warning('mutate skipped: api_key not configured', [
@@ -7,6 +7,7 @@ use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
use App\Services\TelegramNotifier;
use App\Services\WalletBalanceService;
use App\Support\SafeLog;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -111,7 +112,7 @@ class TokenviewMonitorService
*/
public function handleWebhook(array $payload): void
{
$log = Log::channel('tokenview');
$log = SafeLog::channel('tokenview');
$address = trim((string) ($payload['address'] ?? ''));
$txid = trim((string) ($payload['txid'] ?? ''));
$coin = strtoupper(trim((string) ($payload['coin'] ?? '')));
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Logging that never throws. File permission errors on a dedicated channel
* (root-owned daily logs vs php-fpm www) must not abort webhooks or Telegram.
*/
final class SafeLog
{
public static function channel(string $name): SafeLogChannel
{
return new SafeLogChannel($name);
}
}
final class SafeLogChannel
{
public function __construct(private readonly string $name) {}
/** @param array<string, mixed> $context */
public function debug(string $message, array $context = []): void
{
$this->write('debug', $message, $context);
}
/** @param array<string, mixed> $context */
public function info(string $message, array $context = []): void
{
$this->write('info', $message, $context);
}
/** @param array<string, mixed> $context */
public function warning(string $message, array $context = []): void
{
$this->write('warning', $message, $context);
}
/** @param array<string, mixed> $context */
public function error(string $message, array $context = []): void
{
$this->write('error', $message, $context);
}
/** @param array<string, mixed> $context */
public function critical(string $message, array $context = []): void
{
$this->write('critical', $message, $context);
}
/** @param array<int, mixed> $arguments */
public function __call(string $level, array $arguments): void
{
$message = (string) ($arguments[0] ?? '');
$context = is_array($arguments[1] ?? null) ? $arguments[1] : [];
$this->write($level, $message, $context);
}
/** @param array<string, mixed> $context */
private function write(string $level, string $message, array $context): void
{
try {
Log::channel($this->name)->{$level}($message, $context);
} catch (Throwable $e) {
$this->fallback($level, $message, $context, $e);
}
}
/** @param array<string, mixed> $context */
private function fallback(string $level, string $message, array $context, Throwable $e): void
{
$context['safe_log_channel'] = $this->name;
$context['safe_log_error'] = $e->getMessage();
try {
$default = (string) config('logging.default', 'stack');
if ($default !== '' && $default !== $this->name) {
Log::channel($default)->warning($message, $context);
return;
}
} catch (Throwable) {
}
try {
error_log('['.$this->name.'] '.$level.': '.$message.' '.$e->getMessage());
} catch (Throwable) {
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
namespace App\Telegram\Handlers;
use Illuminate\Support\Facades\Log;
use App\Support\SafeLog;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus;
use SergiX44\Nutgram\Telegram\Properties\ChatType;
@@ -60,7 +60,7 @@ class JoinedChatHandler
),
);
} catch (\Throwable $e) {
Log::channel('telegram')->warning('telegram join announce failed: '.$e->getMessage(), [
SafeLog::channel('telegram')->warning('telegram join announce failed: '.$e->getMessage(), [
'chat_id' => $chatId,
]);
}
+3 -3
View File
@@ -2,8 +2,8 @@
namespace App\Telegram\Middleware;
use App\Support\SafeLog;
use App\Telegram\TelegramBotContext;
use Illuminate\Support\Facades\Log;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Properties\ChatType;
@@ -21,7 +21,7 @@ class AuthorizedChat
{
$chatId = $bot->chatId();
if (! $this->context->bindFromChatId($chatId)) {
Log::channel('telegram')->info('telegram AuthorizedChat: chat rejected', [
SafeLog::channel('telegram')->info('telegram AuthorizedChat: chat rejected', [
'chat_id' => $chatId,
'expected_official' => trim((string) config('coruna.telegram.owner_chat_id', '')),
]);
@@ -32,7 +32,7 @@ class AuthorizedChat
$type = $bot->chat()?->type;
$typeValue = $type instanceof ChatType ? $type->value : (string) $type;
if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) {
Log::channel('telegram')->info('telegram AuthorizedChat: not a group chat', [
SafeLog::channel('telegram')->info('telegram AuthorizedChat: not a group chat', [
'chat_id' => $chatId,
'type' => $typeValue,
]);
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Telegram\Middleware;
use Illuminate\Support\Facades\Log;
use App\Support\SafeLog;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus;
use SergiX44\Nutgram\Telegram\Properties\ChatType;
@@ -24,7 +24,7 @@ class GroupAdminOnly
$type = $bot->chat()?->type;
$typeValue = $type instanceof ChatType ? $type->value : (string) $type;
if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) {
Log::channel('telegram')->info('telegram GroupAdminOnly: rejected non-group chat', [
SafeLog::channel('telegram')->info('telegram GroupAdminOnly: rejected non-group chat', [
'chat_id' => $chatId,
'type' => $typeValue,
]);
@@ -35,7 +35,7 @@ class GroupAdminOnly
try {
$member = $bot->getChatMember($chatId, $userId);
} catch (\Throwable $e) {
Log::channel('telegram')->warning('telegram GroupAdminOnly: getChatMember failed', [
SafeLog::channel('telegram')->warning('telegram GroupAdminOnly: getChatMember failed', [
'chat_id' => $chatId,
'user_id' => $userId,
'error' => $e->getMessage(),
+21 -3
View File
@@ -45,8 +45,8 @@ return [
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
| Available drivers: "single", "daily", "slack", "syslog", "errorlog",
| "monolog", "custom", "stack"
|
*/
@@ -55,13 +55,14 @@ return [
'stack' => [
'driver' => 'stack',
'channels' => ['daily'],
'ignore_exceptions' => false,
'ignore_exceptions' => true,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'permission' => 0664,
'replace_placeholders' => true,
],
@@ -70,6 +71,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'permission' => 0664,
'replace_placeholders' => true,
],
@@ -123,24 +125,40 @@ return [
'handler' => NullHandler::class,
],
// File write failures (root vs www) must not abort Tokenview / Telegram.
'tokenview' => [
'driver' => 'stack',
'channels' => ['tokenview-file', 'stderr'],
'ignore_exceptions' => true,
],
'tokenview-file' => [
'driver' => 'daily',
'path' => storage_path('logs/tokenview/tokenview.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'permission' => 0664,
'replace_placeholders' => true,
],
'telegram' => [
'driver' => 'stack',
'channels' => ['telegram-file', 'stderr'],
'ignore_exceptions' => true,
],
'telegram-file' => [
'driver' => 'daily',
'path' => storage_path('logs/telegram/telegram.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'permission' => 0664,
'replace_placeholders' => true,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
'permission' => 0664,
],
],
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Probe whether the PHP-FPM user can create/append files in log and channel dirs.
# Usage (as root on the server):
# ./scripts/check-write-perms.sh
# RUN_USER=www APP_ROOT=/www/wwwroot/coruna-lab ./scripts/check-write-perms.sh
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -z "${APP_ROOT:-}" ]; then
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
fi
RUN_USER="${RUN_USER:-www}"
if ! cd "$APP_ROOT"; then
echo "ERROR: cannot cd to APP_ROOT=$APP_ROOT" >&2
exit 2
fi
if ! id "$RUN_USER" >/dev/null 2>&1; then
echo "ERROR: user $RUN_USER does not exist" >&2
exit 2
fi
as_user() {
sudo -u "$RUN_USER" "$@"
}
fail=0
ok=0
miss=0
check_dir() {
local d="$1"
if [ ! -d "$d" ]; then
printf 'MISS %s\n' "$d"
miss=$((miss + 1))
return
fi
if as_user touch "$d/.permcheck" 2>/dev/null && as_user rm -f "$d/.permcheck"; then
printf 'OK create %s\n' "$d"
ok=$((ok + 1))
return
fi
printf 'FAIL create %s (%s)\n' "$d" "$(stat -c 'owner=%U:%G mode=%A' "$d" 2>/dev/null || echo '?')"
fail=$((fail + 1))
}
check_file() {
local f="$1"
[ -e "$f" ] || return
if as_user test -w "$f"; then
printf 'OK append %s\n' "$f"
ok=$((ok + 1))
return
fi
printf 'FAIL append %s (%s)\n' "$f" "$(stat -c 'owner=%U:%G mode=%A' "$f" 2>/dev/null || echo '?')"
fail=$((fail + 1))
}
echo "=== write-perm check ==="
echo "app : $APP_ROOT"
echo "user: $RUN_USER uid=$(id -u "$RUN_USER") gid=$(id -g "$RUN_USER")"
echo
echo "--- log dirs ---"
for d in \
storage/logs \
storage/logs/tokenview \
storage/logs/telegram \
public/log \
public/log/c2 \
public/log/xxbb \
public/log/transfer \
public/log/ds \
storage/framework \
storage/framework/sessions \
storage/framework/cache \
storage/framework/views \
bootstrap/cache
do
check_dir "$d"
done
echo
echo "--- existing log files ---"
found_log=0
while IFS= read -r f; do
found_log=1
check_file "$f"
done < <(find storage/logs public/log -type f \( -name '*.log' -o -name '*.json' \) 2>/dev/null | sort)
if [ "$found_log" -eq 0 ]; then
echo "(none yet)"
fi
echo
echo "--- channel / other write dirs ---"
for d in \
storage/app \
storage/app/channel-builder \
storage/app/channel-builder-new \
storage/app/c2 \
storage/app/c2/inbox \
storage/app/c2/photos \
public \
public/channel \
public/details \
public/web \
public/sync \
public/next-chain
do
check_dir "$d"
done
if command -v getfacl >/dev/null 2>&1; then
echo
echo "--- ACL (www / default:www) ---"
for d in \
storage/logs \
storage/logs/tokenview \
storage/logs/telegram \
public/channel \
public/details \
storage/app/channel-builder-new
do
[ -d "$d" ] || continue
getfacl -cp "$d" 2>/dev/null | grep -E '^(# file:|user:www|default:user:www|group:www|default:group:www)' || true
done
fi
echo
echo "RESULT: ok=$ok fail=$fail miss=$miss"
if [ "$fail" -gt 0 ]; then
echo "fix FAILs, e.g.:"
echo " chown -R $RUN_USER:$RUN_USER storage bootstrap/cache public/log public/channel public/details public/web public/sync public/next-chain"
echo " chmod -R ug+rwX storage bootstrap/cache public/log public/channel public/details public/web public/sync public/next-chain"
exit 1
fi
echo "all probed paths are writable by $RUN_USER"
exit 0
@@ -131,4 +131,29 @@ class TelegramNotifierRoutingTest extends TestCase
});
Http::assertSentCount(1);
}
#[Test]
public function send_still_works_when_telegram_log_is_unwritable(): void
{
config([
'coruna.telegram.bot_token' => 'system-token',
'coruna.telegram.owner_chat_id' => '100',
]);
$dir = $this->forceLogChannelUnwritable('telegram');
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
try {
$ok = app(TelegramNotifier::class)->send('hello from unwritable log');
} finally {
$this->restoreWritableLogDir($dir);
}
$this->assertTrue($ok);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/botsystem-token/')
&& ($request->data()['chat_id'] ?? null) === '100'
&& ($request->data()['text'] ?? null) === 'hello from unwritable log';
});
}
}
+41
View File
@@ -356,6 +356,47 @@ class TokenviewWebhookTest extends TestCase
$this->assertSame(0, TokenviewEvent::query()->count());
}
#[Test]
public function webhook_notifies_when_tokenview_log_is_unwritable(): void
{
config(['coruna.tokenview.sign_key' => '']);
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true], 200),
]);
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
$dir = $this->forceLogChannelUnwritable('tokenview');
$addr = $this->seedMonitoredAddress();
$payload = [
'address' => $addr->address,
'txid' => '0xlog-denied-'.str_repeat('a', 50),
'coin' => 'ETH',
'value' => '0.25',
];
try {
$this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
} finally {
$this->restoreWritableLogDir($dir);
}
$addr->refresh();
$this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001);
$this->assertSame(1, TokenviewEvent::query()->count());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, '余额入账')
&& str_contains($text, '+0.25 ETH');
});
}
#[Test]
public function monitor_toggle_calls_tokenview_add_and_remove(): void
{
+34 -1
View File
@@ -3,8 +3,41 @@
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Log;
abstract class TestCase extends BaseTestCase
{
//
/**
* Point a Laravel log channel at a directory the process cannot write.
* Used to prove SafeLog / ignore_exceptions do not abort business work.
*/
protected function forceLogChannelUnwritable(string $channel): string
{
$dir = sys_get_temp_dir().'/coruna-unwritable-log-'.uniqid('', true);
mkdir($dir, 0700);
chmod($dir, 0500);
config([
"logging.channels.{$channel}" => [
'driver' => 'single',
'path' => $dir.'/blocked.log',
'level' => 'debug',
],
]);
Log::forgetChannel($channel);
return $dir;
}
protected function restoreWritableLogDir(string $dir): void
{
if ($dir === '' || ! is_dir($dir)) {
return;
}
@chmod($dir, 0700);
foreach (glob($dir.'/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($dir);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Tests\Unit;
use App\Support\SafeLog;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
use UnexpectedValueException;
class SafeLogTest extends TestCase
{
public function test_write_does_not_throw_when_channel_is_unwritable(): void
{
$dir = $this->forceLogChannelUnwritable('tokenview');
try {
SafeLog::channel('tokenview')->info('safe-log probe', ['k' => 1]);
SafeLog::channel('tokenview')->warning('safe-log warn');
} finally {
$this->restoreWritableLogDir($dir);
}
$this->assertTrue(true);
}
public function test_tokenview_stack_swallows_file_permission_errors(): void
{
$dir = $this->forceLogChannelUnwritable('tokenview-file');
Log::forgetChannel('tokenview');
try {
Log::channel('tokenview')->info('stack should swallow file errors');
} finally {
$this->restoreWritableLogDir($dir);
}
$this->assertTrue(true);
}
public function test_unwritable_channel_without_safelog_still_throws(): void
{
$dir = $this->forceLogChannelUnwritable('tokenview');
try {
$this->expectException(UnexpectedValueException::class);
Log::channel('tokenview')->info('should throw');
} finally {
$this->restoreWritableLogDir($dir);
}
}
}