[Fix] Internal cron scheduler blocking view responses
Internal cron is now triggered by an axios request from the frontend so it can truly run in a separate request. Fixes #167
This commit is contained in:
@@ -18,6 +18,7 @@ public function handle(CaldavService $service)
|
|||||||
// only run every 5 minutes although the task is called every minute
|
// only run every 5 minutes although the task is called every minute
|
||||||
$cacheKey = 'caldav_sync_last_run';
|
$cacheKey = 'caldav_sync_last_run';
|
||||||
if (\Illuminate\Support\Facades\Cache::has($cacheKey)) {
|
if (\Illuminate\Support\Facades\Cache::has($cacheKey)) {
|
||||||
|
Log::info('CalDAV sync Throttled');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
Cache::put($cacheKey, true, 300);
|
Cache::put($cacheKey, true, 300);
|
||||||
@@ -33,6 +34,7 @@ public function handle(CaldavService $service)
|
|||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log::info("Synced " . count($todos) . " todos.");
|
||||||
$this->info("Synced " . count($todos) . " todos.");
|
$this->info("Synced " . count($todos) . " todos.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@@ -39,10 +39,9 @@ public function share(Request $request): array
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'auth' => [
|
'auth' => ['user' => $request->user(),],
|
||||||
'user' => $request->user(),
|
|
||||||
],
|
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
'cron' => Setting::get('app.cron_method') === 'request'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use App\Models\Setting;
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduleListener
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle the event.
|
|
||||||
*
|
|
||||||
* This listener is attached to RouteMatched when app.schedule_method == 'internal'.
|
|
||||||
* It will trigger the scheduler at most once per minute (throttle via cache).
|
|
||||||
*/
|
|
||||||
public function handle($event)
|
|
||||||
{
|
|
||||||
// TODO: this check is also done, when registering the listener EventServiceProvider.php
|
|
||||||
// it can probably be removed here safely which would spare a database call on each request
|
|
||||||
|
|
||||||
// only run when internal scheduling is enabled (defensive)
|
|
||||||
$method = Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal';
|
|
||||||
if ($method !== 'internal') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// throttle key: run at most once every 55 seconds
|
|
||||||
$cacheKey = 'caramel_scheduler_last_run';
|
|
||||||
$ttlSeconds = 55;
|
|
||||||
|
|
||||||
if (Cache::has($cacheKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// mark as run
|
|
||||||
Cache::put($cacheKey, true, $ttlSeconds);
|
|
||||||
|
|
||||||
try {
|
|
||||||
Log::info('Triggering scheduler via ScheduleListener');
|
|
||||||
Artisan::call('schedule:run');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('Error running scheduler: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,6 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Routing\Events\RouteMatched;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Listeners\ScheduleListener;
|
|
||||||
use App\Jobs\CheckInvoiceDueDatesJob;
|
use App\Jobs\CheckInvoiceDueDatesJob;
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
@@ -22,16 +17,9 @@ public function boot(): void
|
|||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
if (Schema::hasTable('settings')) {
|
// // TODO: read where to put these or ask in the forums
|
||||||
$method = Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal';
|
// // it seems to work here, but where is the apropriate place?
|
||||||
if ($method === 'internal') {
|
// // Kernel::schedule did not work
|
||||||
Event::listen(RouteMatched::class, [ScheduleListener::class, 'handle']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: read where to put these or ask in the forums
|
|
||||||
// it seems to work here, but where is the apropriate place?
|
|
||||||
// Kernel::schedule did not work
|
|
||||||
Schedule::command('caldav:sync')
|
Schedule::command('caldav:sync')
|
||||||
->everyMinute()
|
->everyMinute()
|
||||||
->withoutOverlapping();
|
->withoutOverlapping();
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ public function run(): void
|
|||||||
{
|
{
|
||||||
Setting::updateOrCreate(['key' => 'invoices.number_format'], ['value' => 'RE-{number}']);
|
Setting::updateOrCreate(['key' => 'invoices.number_format'], ['value' => 'RE-{number}']);
|
||||||
Setting::updateOrCreate(['key' => 'invoices.number_start'], ['value' => '1']);
|
Setting::updateOrCreate(['key' => 'invoices.number_start'], ['value' => '1']);
|
||||||
Setting::updateOrCreate(['key' => 'app.schedule_method'], ['value' => '1']);
|
Setting::updateOrCreate(['key' => 'app.cron_method'], ['value' => 'request']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3'
|
import { Head, usePage } from '@inertiajs/vue3'
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
@@ -7,22 +7,38 @@ import { Toaster } from 'vue-sonner'
|
|||||||
import { Info, CircleAlert, CircleCheck, LoaderCircle, Ban } from "lucide-vue-next"
|
import { Info, CircleAlert, CircleCheck, LoaderCircle, Ban } from "lucide-vue-next"
|
||||||
import { Button } from '@/components/ui/crm-button'
|
import { Button } from '@/components/ui/crm-button'
|
||||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import { usePage } from '@inertiajs/vue3';
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'
|
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'
|
||||||
import { alertStore } from '@/stores/alertStore';
|
import { alertStore } from '@/stores/alertStore';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const isOpen = usePage().props.sidebarOpen;
|
|
||||||
const alert = alertStore()
|
const alert = alertStore()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string;
|
title: string
|
||||||
}>();
|
}>();
|
||||||
|
const isOpen = usePage().props.sidebarOpen;
|
||||||
|
const cron = usePage().props.cron
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
||||||
document.body.classList.add('is-mac')
|
document.body.classList.add('is-mac')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cron) {
|
||||||
|
triggerWebcron()
|
||||||
|
setInterval(triggerWebcron, 30000)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const triggerWebcron = async function () {
|
||||||
|
await axios.get('/webcron')
|
||||||
|
.catch(function (response) {
|
||||||
|
if (response.status >= 400) {
|
||||||
|
console.error(response.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -2,11 +2,35 @@
|
|||||||
// import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
|
// import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
|
||||||
// import AuthLayout from '@/layouts/auth/AuthCardLayout.vue';
|
// import AuthLayout from '@/layouts/auth/AuthCardLayout.vue';
|
||||||
import AuthLayout from '@/layouts/auth/AuthSplitLayout.vue';
|
import AuthLayout from '@/layouts/auth/AuthSplitLayout.vue';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { usePage } from '@inertiajs/vue3'
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}>();
|
}>();
|
||||||
|
const cron = usePage().props.cron
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
||||||
|
document.body.classList.add('is-mac')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cron) {
|
||||||
|
triggerWebcron()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const triggerWebcron = async function () {
|
||||||
|
await axios.get('/webcron')
|
||||||
|
.catch(function (response) {
|
||||||
|
if (response.status >= 400) {
|
||||||
|
console.error(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+32
-24
@@ -9,6 +9,7 @@
|
|||||||
use App\Http\Controllers\InvoiceController;
|
use App\Http\Controllers\InvoiceController;
|
||||||
use App\Http\Controllers\CustomerController;
|
use App\Http\Controllers\CustomerController;
|
||||||
use App\Http\Controllers\ProductController;
|
use App\Http\Controllers\ProductController;
|
||||||
|
use App\Models\Setting;
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
|
||||||
@@ -54,8 +55,6 @@
|
|||||||
// Products
|
// Products
|
||||||
Route::get('products', [ProductController::class, 'show'])->name('products');
|
Route::get('products', [ProductController::class, 'show'])->name('products');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Route::get('timesheets', function () {
|
Route::get('timesheets', function () {
|
||||||
return Inertia::render('Timesheets');
|
return Inertia::render('Timesheets');
|
||||||
})->name('timesheets');
|
})->name('timesheets');
|
||||||
@@ -64,53 +63,62 @@
|
|||||||
Route::get('proceduralDocumentation', function () {
|
Route::get('proceduralDocumentation', function () {
|
||||||
return Inertia::render('ProceduralDocumentation');
|
return Inertia::render('ProceduralDocumentation');
|
||||||
})->name('proceduralDocumentation');
|
})->name('proceduralDocumentation');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Web cron route
|
| Web cron route
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Example: GET /webcron?token=SECRET or set header X-WEBCron-Token: SECRET
|
| Example: GET /webcron?token=SECRET or set header X-WEBCron-Token: SECRET
|
||||||
| Configure secret in .env as WEBCRON_SECRET (optional). If no secret is set,
|
| Configure secret in .env as WEBCRON_SECRET (optional). If no secret is set,
|
||||||
| the route is open (not recommended in production).
|
| the route is open (not recommended in production).
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
Route::get('/webcron', function (Request $request) {
|
Route::get('/webcron', function (Request $request) {
|
||||||
// only allow if scheduling method is webcron
|
|
||||||
$method = \App\Models\Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal';
|
// Return early of cron method is set to anything other then 'webcron' or 'request'
|
||||||
if ($method !== 'webcron') {
|
$method = \App\Models\Setting::where('key', 'app.cron_method')->value('value') ?? 'request';
|
||||||
|
if (!in_array($method, ['webcron', 'request'])) {
|
||||||
return response('Not Found', 404);
|
return response('Not Found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$secret = env('WEBCRON_SECRET', null);
|
// TODO: Only allow requests from the same host or with the secret token
|
||||||
|
// $clientHost = ;
|
||||||
|
// $serverHost = ;
|
||||||
|
$sameHost = true;
|
||||||
|
|
||||||
// basic token protection
|
$secret = env('WEBCRON_SECRET', null);
|
||||||
|
$token = null;
|
||||||
if ($secret) {
|
if ($secret) {
|
||||||
$token = $request->query('token') ?? $request->header('X-WEBCron-Token');
|
$token = $request->query('token') ?? $request->header('X-WEBCron-Token');
|
||||||
if (!$token || !hash_equals((string)$secret, (string)$token)) {
|
}
|
||||||
|
if (
|
||||||
|
!hash_equals((string)$secret, (string)$token) &&
|
||||||
|
!$sameHost
|
||||||
|
) {
|
||||||
return response('Forbidden', 403);
|
return response('Forbidden', 403);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// quick throttle to avoid abuse (server-side)
|
// Throttle to avoid abuse (server-side)
|
||||||
$cacheKey = 'caramel_webcron_last_run';
|
$cacheKey = 'caramel_webcron_last_run';
|
||||||
if (\Illuminate\Support\Facades\Cache::has($cacheKey)) {
|
if (\Illuminate\Support\Facades\Cache::has($cacheKey)) {
|
||||||
return response('Throttled', 429);
|
return response('Throttled', 304);
|
||||||
}
|
}
|
||||||
Cache::put($cacheKey, true, 55);
|
Cache::put($cacheKey, true, 55);
|
||||||
|
|
||||||
|
// Run scheduler
|
||||||
try {
|
try {
|
||||||
Log::info('Triggering scheduler via /webcron route');
|
Log::info('Triggering scheduler via /webcron route');
|
||||||
|
$output = '';
|
||||||
Artisan::call('schedule:run');
|
Artisan::call('schedule:run');
|
||||||
return response('OK', 200);
|
return response('OK', 200);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Error running scheduler: ' . $e->getMessage());
|
Log::error('Error running scheduler: ' . $e->getMessage());
|
||||||
return response('Error', 500);
|
return response('Error', 500);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
require __DIR__ . '/settings.php';
|
require __DIR__ . '/settings.php';
|
||||||
require __DIR__ . '/auth.php';
|
require __DIR__ . '/auth.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user