diff --git a/README.md b/README.md index 893f658..336e49a 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,7 @@ POST-ом без авторизации. `core/Dockerfile` (serversideup/php), compose с `scheduler` и `worker`, Caddy — `deploy/Caddyfile.snippet` в хаб sekai. + +Compose в репозитории — dev-стек (`Dockerfile.dev`, `artisan serve`); прод-сборка +через `core/Dockerfile` (serversideup/php) описана, но не проверена — отдельный шаг +перед деплоем. diff --git a/core/app/Enums/EventState.php b/core/app/Enums/EventState.php index 6622e3d..4d996cb 100644 --- a/core/app/Enums/EventState.php +++ b/core/app/Enums/EventState.php @@ -36,4 +36,13 @@ enum EventState: string array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()), )); } + + /** @return list */ + public static function badgeValues(): array + { + return array_values(array_map( + fn (self $s) => $s->value, + array_filter(self::cases(), fn (self $s) => $s->countsInBadge()), + )); + } } diff --git a/core/app/Http/Controllers/InboxController.php b/core/app/Http/Controllers/InboxController.php index 8ce6b8e..a4e4d1e 100644 --- a/core/app/Http/Controllers/InboxController.php +++ b/core/app/Http/Controllers/InboxController.php @@ -91,7 +91,7 @@ class InboxController extends Controller /** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */ public static function safeLink(?string $link): ?string { - return $link !== null && preg_match('#^(/(?!/)|https?://)#i', $link) === 1 ? $link : null; + return $link !== null && preg_match('#^(/(?![/\\\\])|https?://)#i', $link) === 1 ? $link : null; } public static function relative(int $diff): string diff --git a/core/app/Http/Controllers/Me/ChannelsController.php b/core/app/Http/Controllers/Me/ChannelsController.php index 5ee0d6f..e1ab71d 100644 --- a/core/app/Http/Controllers/Me/ChannelsController.php +++ b/core/app/Http/Controllers/Me/ChannelsController.php @@ -31,8 +31,8 @@ class ChannelsController extends Controller $data = $request->validate([ 'type' => ['required', 'in:webhook'], 'config' => ['required', 'array'], - 'config.deliver_url' => ['required', 'url'], - 'config.presence_url' => ['required', 'url'], + 'config.deliver_url' => ['required', 'url:http,https'], + 'config.presence_url' => ['required', 'url:http,https'], ], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']); $channel = $user->channels()->create([ diff --git a/core/app/Http/Controllers/Me/EventsController.php b/core/app/Http/Controllers/Me/EventsController.php index ff71a68..93a1fd9 100644 --- a/core/app/Http/Controllers/Me/EventsController.php +++ b/core/app/Http/Controllers/Me/EventsController.php @@ -10,9 +10,11 @@ use App\Ingest\EventUpserter; use App\Models\Event; use App\Models\Source; use App\Models\User; +use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; class EventsController extends Controller { @@ -22,11 +24,14 @@ class EventsController extends Controller { /** @var User $user */ $user = $request->attributes->get('user'); + $request->validate([ + 'state' => ['sometimes', Rule::in(array_map(fn ($s) => $s->value, EventState::cases()))], + ]); $states = $request->query('state') ? [$request->query('state')] : EventState::nonTerminalValues(); - $events = $user->events()->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get(); + $events = $user->events()->with('source')->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get(); return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]); } @@ -66,7 +71,7 @@ class EventsController extends Controller 'source_ref' => 'manual:'.Str::ulid(), 'topic' => '', 'due_at' => $dueAt, - 'fire_at' => \Carbon\CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'), + 'fire_at' => CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'), 'after_due' => 'keep', 'payload' => array_filter([ 'title' => $data['title'], diff --git a/core/app/Http/Controllers/Me/ProfileController.php b/core/app/Http/Controllers/Me/ProfileController.php index 87dc471..c2fe1a7 100644 --- a/core/app/Http/Controllers/Me/ProfileController.php +++ b/core/app/Http/Controllers/Me/ProfileController.php @@ -10,7 +10,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Cache; use Illuminate\Validation\Rule; -use Illuminate\Validation\Validator; +use Illuminate\Validation\ValidationException; class ProfileController extends Controller { @@ -33,7 +33,7 @@ class ProfileController extends Controller $start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5); $end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5); if ($end >= $start) { - throw \Illuminate\Validation\ValidationException::withMessages([ + throw ValidationException::withMessages([ 'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start', ]); } @@ -64,7 +64,7 @@ class ProfileController extends Controller 'tz' => $user->tz, 'quiet_start' => substr($user->quiet_start, 0, 5), 'quiet_end' => substr($user->quiet_end, 0, 5), - 'badge' => $user->events()->whereIn('state', [EventState::Today->value, EventState::Overdue->value])->count(), + 'badge' => $user->events()->whereIn('state', EventState::badgeValues())->count(), ]; } } diff --git a/core/app/Http/Requests/UpsertEventsRequest.php b/core/app/Http/Requests/UpsertEventsRequest.php index eb23703..7c8c48e 100644 --- a/core/app/Http/Requests/UpsertEventsRequest.php +++ b/core/app/Http/Requests/UpsertEventsRequest.php @@ -27,7 +27,7 @@ class UpsertEventsRequest extends FormRequest 'events.*.payload' => ['required', 'array'], 'events.*.payload.title' => ['required', 'string'], 'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'], - 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?!/)|https?://)#'], + 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?![/\\\\])|https?://)#i'], 'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'], ]; } diff --git a/core/bootstrap/app.php b/core/bootstrap/app.php index 7e6a507..5e83651 100644 --- a/core/bootstrap/app.php +++ b/core/bootstrap/app.php @@ -20,7 +20,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'auth.source' => AuthenticateSource::class, 'remote.user' => RemoteUser::class, ]); - $middleware->validateCsrfTokens(except: ['a/*', 'hooks/*']); + $middleware->validateCsrfTokens(except: ['a/*', 'hooks/*', 'me/heartbeat']); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/core/public/inbox.js b/core/public/inbox.js index 883a8f0..3d941eb 100644 --- a/core/public/inbox.js +++ b/core/public/inbox.js @@ -1,9 +1,13 @@ (() => { const csrf = document.querySelector('meta[name=csrf-token]').content; - const call = (method, url, body) => fetch(url, { - method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf }, - body: body ? JSON.stringify(body) : undefined, - }); + const call = async (method, url, body) => { + const r = await fetch(url, { + method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf }, + body: body ? JSON.stringify(body) : undefined, + }); + if (r.status === 419) { location.reload(); return r; } + return r; + }; const showErr = async (el, r) => { const d = await r.json().catch(() => ({})); el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка'; @@ -49,7 +53,10 @@ const card = btn.closest('.card'); card.querySelectorAll('button').forEach(b => b.disabled = true); const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`); - if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false); + if (r.ok) location.reload(); else if (r.status !== 419) { + card.querySelectorAll('button').forEach(b => b.disabled = false); + showErr(document.getElementById('card-err'), r); + } })); // Настройки diff --git a/core/resources/views/inbox.blade.php b/core/resources/views/inbox.blade.php index 3b20f20..bb3e50d 100644 --- a/core/resources/views/inbox.blade.php +++ b/core/resources/views/inbox.blade.php @@ -90,6 +90,7 @@ footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padd 05 +
@@ -161,7 +162,7 @@ footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padd @foreach($channels as $ch)
{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }} - {{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }} + {{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.($ch['config']['chat_id'] ?? '—') : ($ch['config']['deliver_url'] ?? '—')) }} @if($ch['type'] !== 'web')@endif
@endforeach diff --git a/core/routes/web.php b/core/routes/web.php index 770d9a7..7137d93 100644 --- a/core/routes/web.php +++ b/core/routes/web.php @@ -6,12 +6,23 @@ use App\Http\Controllers\Me\ChannelsController; use App\Http\Controllers\Me\EventsController as MeEvents; use App\Http\Controllers\Me\ProfileController; use App\Http\Controllers\TelegramWebhookController; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; +use Illuminate\View\Middleware\ShareErrorsFromSession; -// Публичные: без SSO, без CSRF (см. bootstrap/app.php) -Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack'); -Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done'); -Route::post('/hooks/telegram', TelegramWebhookController::class); +// Публичные: без SSO, без CSRF (см. bootstrap/app.php), без сессии +Route::withoutMiddleware([ + StartSession::class, + ShareErrorsFromSession::class, + AddQueuedCookiesToResponse::class, + PreventRequestForgery::class, +])->middleware('throttle:60,1')->group(function () { + Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack'); + Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done'); + Route::post('/hooks/telegram', TelegramWebhookController::class); +}); // Под SSO хаба Route::middleware('remote.user')->group(function () { diff --git a/core/tests/Feature/ActionLinksTest.php b/core/tests/Feature/ActionLinksTest.php index 8f7ffd4..b4e2cf5 100644 --- a/core/tests/Feature/ActionLinksTest.php +++ b/core/tests/Feature/ActionLinksTest.php @@ -50,6 +50,11 @@ class ActionLinksTest extends TestCase $this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String()); } + public function test_public_route_sets_no_session_cookie(): void + { + $this->postJson('/a/tok/ack')->assertOk()->assertHeaderMissing('Set-Cookie'); + } + public function test_done_by_token_then_gone(): void { $this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done'); diff --git a/core/tests/Feature/ChannelsApiTest.php b/core/tests/Feature/ChannelsApiTest.php index 3e1353d..14feefa 100644 --- a/core/tests/Feature/ChannelsApiTest.php +++ b/core/tests/Feature/ChannelsApiTest.php @@ -38,6 +38,11 @@ class ChannelsApiTest extends TestCase // telegram подключается только через /start, не через API $this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as) ->assertStatus(422); + // только http/https-схемы + $this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'ftp://x/y', 'presence_url' => 'ftp://x/y']], $this->as) + ->assertStatus(422)->assertJsonValidationErrors(['config.deliver_url', 'config.presence_url']); + $this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'http://home.local/x', 'presence_url' => 'http://home.local/x']], $this->as) + ->assertCreated(); } public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void diff --git a/core/tests/Feature/ClientApiTest.php b/core/tests/Feature/ClientApiTest.php index 6702540..9ddff63 100644 --- a/core/tests/Feature/ClientApiTest.php +++ b/core/tests/Feature/ClientApiTest.php @@ -93,11 +93,18 @@ class ClientApiTest extends TestCase $this->putJson('/api/events', ['events' => [$protocolRelative]], $this->auth()) ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']); + $backslash = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/\evil.com']]); + $this->putJson('/api/events', ['events' => [$backslash]], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']); + $ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42?x=1']]); $this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk(); $ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]); $this->putJson('/api/events', ['events' => [$ok2]], $this->auth())->assertOk(); + + $ok3 = $this->item(['topic' => 'birthday:2028', 'payload' => ['title' => 'x', 'deep_link' => 'HTTPS://docs.example/x']]); + $this->putJson('/api/events', ['events' => [$ok3]], $this->auth())->assertOk(); } public function test_topic_defaults_to_empty_string(): void diff --git a/core/tests/Feature/InboxPageTest.php b/core/tests/Feature/InboxPageTest.php index 7f58c84..679c79e 100644 --- a/core/tests/Feature/InboxPageTest.php +++ b/core/tests/Feature/InboxPageTest.php @@ -80,6 +80,13 @@ class InboxPageTest extends TestCase $this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html); $this->assertStringContainsString('/inbox.js', $html); $this->assertStringContainsString('csrf-token', $html); + // инлайн-ошибка карточки и обработка 419 в клиентском скрипте + $this->assertStringContainsString('card-err', $html); + $this->assertStringContainsString('419', file_get_contents(public_path('inbox.js'))); + // Запланировано — витрина, без действий «Помню»/«Сделано» + $this->assertStringContainsString('>Помню<', $html); + $scheduledSection = substr($html, (int) strpos($html, '予定')); + $this->assertStringNotContainsString('>Помню<', $scheduledSection); } public function test_counter_hidden_when_zero(): void @@ -106,12 +113,20 @@ class InboxPageTest extends TestCase 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Протокольная ссылка', 'deep_link' => '//evil.com/x'], 'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), ]); + Event::create([ + 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'c', + 'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04', + 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Обратный слэш', 'deep_link' => '/\evil.com'], + 'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), + ]); $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); $this->assertStringNotContainsString('href="javascript:', $html); $this->assertStringNotContainsString('href="//evil', $html); + $this->assertStringNotContainsString('href="/\\', $html); $this->assertStringContainsString('Опасное событие', $html); $this->assertStringContainsString('Протокольная ссылка', $html); + $this->assertStringContainsString('Обратный слэш', $html); } } diff --git a/core/tests/Feature/MeApiTest.php b/core/tests/Feature/MeApiTest.php index a49f7ff..e1aa592 100644 --- a/core/tests/Feature/MeApiTest.php +++ b/core/tests/Feature/MeApiTest.php @@ -100,6 +100,7 @@ class MeApiTest extends TestCase $this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a'); $this->getJson('/me/events?state=done', $this->as())->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'b'); + $this->getJson('/me/events?state=garbage', $this->as())->assertStatus(422); } public function test_ack_sets_quiet_until_and_done_closes(): void diff --git a/core/tests/Feature/SchemaTest.php b/core/tests/Feature/SchemaTest.php index 455bde0..58f83cd 100644 --- a/core/tests/Feature/SchemaTest.php +++ b/core/tests/Feature/SchemaTest.php @@ -110,5 +110,6 @@ class SchemaTest extends TestCase $this->assertTrue(EventState::Overdue->countsInBadge()); $this->assertFalse(EventState::Preparing->countsInBadge()); $this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues()); + $this->assertSame(['today', 'overdue'], EventState::badgeValues()); } } diff --git a/core/tests/Feature/TelegramWebhookTest.php b/core/tests/Feature/TelegramWebhookTest.php index 7dc5f96..37825cb 100644 --- a/core/tests/Feature/TelegramWebhookTest.php +++ b/core/tests/Feature/TelegramWebhookTest.php @@ -43,6 +43,13 @@ class TelegramWebhookTest extends TestCase $this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403); } + public function test_public_route_sets_no_session_cookie(): void + { + $this->postJson('/hooks/telegram', ['message' => []]) + ->assertStatus(403) + ->assertHeaderMissing('Set-Cookie'); + } + public function test_start_with_code_links_chat(): void { Cache::put('tg:link:ABC123', $this->user->id, 900); diff --git a/core/tests/Feature/TickTest.php b/core/tests/Feature/TickTest.php index 9d8ab9f..80b90df 100644 --- a/core/tests/Feature/TickTest.php +++ b/core/tests/Feature/TickTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Channels\ChannelRegistry; +use App\Delivery\DeliverJob; use App\Delivery\Tick; use App\Enums\AfterDue; use App\Enums\ChannelType; @@ -20,6 +21,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Queue; use Tests\Support\FakeChannel; use Tests\Support\FakeRegistry; use Tests\TestCase; @@ -29,9 +31,13 @@ class TickTest extends TestCase use RefreshDatabase; private User $user; + private Source $source; + private FakeChannel $web; + private FakeChannel $telegram; + private FakeChannel $webhook; protected function setUp(): void @@ -105,6 +111,18 @@ class TickTest extends TestCase $this->assertSame(2, Delivery::count(), 'новое окно — новая доставка'); } + public function test_pending_row_exists_before_job_runs(): void + { + Queue::fake(); + $this->event('2026-09-30', '2026-08-31'); + $this->webhook->presence = Presence::Present; + + $this->tickAt('2026-09-05T10:00:00Z'); + + $this->assertSame(DeliveryResult::Pending, Delivery::sole()->result); + Queue::assertPushed(DeliverJob::class, 1); + } + public function test_falls_back_to_all_channels_near_window_end(): void { $this->event('2026-09-30', '2026-08-31'); diff --git a/docs/superpowers/specs/2026-09-03-hado-core-design.md b/docs/superpowers/specs/2026-09-03-hado-core-design.md index d305a75..679ac09 100644 --- a/docs/superpowers/specs/2026-09-03-hado-core-design.md +++ b/docs/superpowers/specs/2026-09-03-hado-core-design.md @@ -195,6 +195,9 @@ PUT /api/events `HADO_RETENTION_DAYS` (по умолчанию 90) после перехода в терминальное состояние, чтобы вхождения не копились вечно. +Реализация считает срок от `updated_at`; повторный upsert закрытого события +продлевает хранение — допустимое приближение. + ### Снятие и чтение ``` @@ -467,6 +470,10 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`, В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути (`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`. +Compose в репозитории — dev-стек (`Dockerfile.dev`, `artisan serve`); прод-сборка +через `core/Dockerfile` (serversideup/php) описана, но не проверена — отдельный шаг +перед деплоем. + ## 13. Тесты Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.