Release v1.0.3: post-1.0.2 hotfixes (logging, customer objects, version display, module catalog & installer)
- bootstrap/app.php: restore default Laravel exception logging. The diagnostic reportable() callback no longer returns false unconditionally it only suppresses default reporting when a reporter actually handled the exception, so storage/logs/laravel.log shows errors again on fresh installs. - Customer object creation: fix 500 when notify_recipients is empty (NOT NULL violation). Reconcile drift across migration/validation/form/lang: the field is now treated consistently as an enum (customer|object|both) matching the notification consumers; form uses a <select> instead of free-text input; validation tightened via in: rule; coercion in prepareForValidation keeps the DB invariant intact when the field is empty or missing. - config/app.php: version is now read from the VERSION file at runtime. The previously hardcoded '1.0.0' caused footer, settings, and dashboard to show a stale version after every release. VERSION is now the single source of truth for display. - Module catalog UI: fix render crash (htmlspecialchars on i18n category dict) and disappearing modules on 304 Not Modified responses. SchneespurModuleClient now has a normalizeModule() adapter that bridges server-side field naming (current_version, image_url, i18n category dict) to the internal shape used by controller and views. The catalog body is cached in state, so 304 responses replay the cached catalog instead of falling back to the semantically wrong "installed" list. - Module installer: strip common top-level prefix from module ZIPs to prevent modules/<slug>/<slug>/ double-nesting. The installer now detects whether all ZIP entries share one wrapper folder and strips it during extraction; flat ZIPs continue to work unchanged. Path-traversal validation runs on the original entry names before the strip, so the security guarantee is intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7e27270626
commit
7e1222b022
10 changed files with 171 additions and 32 deletions
|
|
@ -1 +1 @@
|
||||||
1.0.2
|
1.0.3
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,7 @@ class AdminModuleController extends Controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$catalog = $client->fetchCatalog();
|
$catalog = $client->fetchCatalog();
|
||||||
if ($catalog !== null) {
|
|
||||||
$catalogModules = $catalog['modules'] ?? [];
|
$catalogModules = $catalog['modules'] ?? [];
|
||||||
} else {
|
|
||||||
$state = $client->loadState();
|
|
||||||
$catalogModules = $state['installed'] ?? [];
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('schneespur-modules: catalog fetch failed in admin UI', [
|
Log::warning('schneespur-modules: catalog fetch failed in admin UI', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ class StoreCustomerObjectRequest extends FormRequest
|
||||||
'price_amount' => str_replace(',', '.', $this->price_amount),
|
'price_amount' => str_replace(',', '.', $this->price_amount),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notify_recipients is a mode enum consumed as customer|object|both
|
||||||
|
// (see SendJobCompletedNotification / SendCustomerReportEmail).
|
||||||
|
// Fall back to the DB default when the field is missing or empty
|
||||||
|
// so the NOT NULL column never receives a null value.
|
||||||
|
if (! $this->filled('notify_recipients')) {
|
||||||
|
$this->merge(['notify_recipients' => 'customer']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,7 +52,7 @@ class StoreCustomerObjectRequest extends FormRequest
|
||||||
'lon' => ['nullable', 'numeric', 'between:-180,180'],
|
'lon' => ['nullable', 'numeric', 'between:-180,180'],
|
||||||
'auto_notify_email' => ['boolean'],
|
'auto_notify_email' => ['boolean'],
|
||||||
'notification_email' => ['nullable', 'required_if:auto_notify_email,1', 'email', 'max:200'],
|
'notification_email' => ['nullable', 'required_if:auto_notify_email,1', 'email', 'max:200'],
|
||||||
'notify_recipients' => ['nullable', 'string', 'max:1000'],
|
'notify_recipients' => ['required', 'in:customer,object,both'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,19 +28,24 @@ class SchneespurModuleClient
|
||||||
/**
|
/**
|
||||||
* Fetch the module catalog from the server.
|
* Fetch the module catalog from the server.
|
||||||
*
|
*
|
||||||
* Returns parsed catalog array on 200, null on 304 (not modified).
|
* Returns normalized catalog array. On 304 returns the cached normalized
|
||||||
* Throws on HTTP error.
|
* catalog from state (or re-fetches without If-None-Match if no cache).
|
||||||
|
* Returns null only when there is no cache and the server-side state
|
||||||
|
* cannot be reconstructed. Throws on HTTP error.
|
||||||
*/
|
*/
|
||||||
public function fetchCatalog(): ?array
|
public function fetchCatalog(): ?array
|
||||||
{
|
{
|
||||||
$state = $this->loadState();
|
$state = $this->loadState();
|
||||||
$etag = $state['catalog_etag'] ?? null;
|
$etag = $state['catalog_etag'] ?? null;
|
||||||
|
$cached = $state['catalog_cache'] ?? null;
|
||||||
|
|
||||||
$url = $this->serverUrl . str_replace('{slug}', $this->collectionSlug, $this->catalogEndpoint);
|
$url = $this->serverUrl . str_replace('{slug}', $this->collectionSlug, $this->catalogEndpoint);
|
||||||
|
|
||||||
$request = Http::acceptJson()->timeout($this->timeout);
|
$request = Http::acceptJson()->timeout($this->timeout);
|
||||||
|
|
||||||
if ($etag) {
|
// Only send If-None-Match when we actually have a cached body to fall
|
||||||
|
// back on — otherwise a 304 leaves us with nothing to display.
|
||||||
|
if ($etag && $cached !== null) {
|
||||||
$request = $request->withHeaders(['If-None-Match' => $etag]);
|
$request = $request->withHeaders(['If-None-Match' => $etag]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,7 +56,7 @@ class SchneespurModuleClient
|
||||||
$state['synced_at'] = now()->toIso8601String();
|
$state['synced_at'] = now()->toIso8601String();
|
||||||
$this->writeState($state);
|
$this->writeState($state);
|
||||||
|
|
||||||
return null;
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($response->status() === 404) {
|
if ($response->status() === 404) {
|
||||||
|
|
@ -69,13 +74,19 @@ class SchneespurModuleClient
|
||||||
throw new RuntimeException("Katalog-Fetch fehlgeschlagen: HTTP {$response->status()}");
|
throw new RuntimeException("Katalog-Fetch fehlgeschlagen: HTTP {$response->status()}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$catalog = $response->json();
|
$raw = $response->json();
|
||||||
if (! is_array($catalog) || ! array_key_exists('modules', $catalog)) {
|
if (! is_array($raw) || ! array_key_exists('modules', $raw)) {
|
||||||
throw new RuntimeException('Katalog-Response hat unerwartete Form');
|
throw new RuntimeException('Katalog-Response hat unerwartete Form');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$catalog = [
|
||||||
|
'collection' => $raw['collection'] ?? null,
|
||||||
|
'modules' => array_map(fn ($m) => $this->normalizeModule($m), $raw['modules']),
|
||||||
|
];
|
||||||
|
|
||||||
$newEtag = $response->header('ETag');
|
$newEtag = $response->header('ETag');
|
||||||
$state['catalog_etag'] = $newEtag ?: ($state['catalog_etag'] ?? null);
|
$state['catalog_etag'] = $newEtag ?: ($state['catalog_etag'] ?? null);
|
||||||
|
$state['catalog_cache'] = $catalog;
|
||||||
$state['synced_at'] = now()->toIso8601String();
|
$state['synced_at'] = now()->toIso8601String();
|
||||||
$this->writeState($state);
|
$this->writeState($state);
|
||||||
|
|
||||||
|
|
@ -85,6 +96,36 @@ class SchneespurModuleClient
|
||||||
return $catalog;
|
return $catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a server-side module entry to the internal shape expected by the
|
||||||
|
* admin controller and views. The server may evolve its field names
|
||||||
|
* independently — this is the single place where that drift is bridged.
|
||||||
|
*/
|
||||||
|
private function normalizeModule(array $raw): array
|
||||||
|
{
|
||||||
|
$appLocale = app()->getLocale();
|
||||||
|
$primary = $raw['primary_locale'] ?? $appLocale;
|
||||||
|
|
||||||
|
$category = $raw['category'] ?? null;
|
||||||
|
if (is_array($category)) {
|
||||||
|
$category = self::i18nPick($category, $primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'slug' => $raw['slug'] ?? null,
|
||||||
|
'name' => $raw['name'] ?? [],
|
||||||
|
'description' => $raw['description'] ?? [],
|
||||||
|
'version' => $raw['current_version'] ?? $raw['version'] ?? null,
|
||||||
|
'category' => $category,
|
||||||
|
'image' => $raw['image_url'] ?? $raw['image'] ?? null,
|
||||||
|
'download_url' => $raw['download_url'] ?? null,
|
||||||
|
'sha256' => $raw['sha256'] ?? null,
|
||||||
|
'size_bytes' => $raw['size_bytes'] ?? null,
|
||||||
|
'requires_permissions' => $raw['requires_permissions'] ?? [],
|
||||||
|
'primary_locale' => $primary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a module ZIP, verify size and SHA256.
|
* Download a module ZIP, verify size and SHA256.
|
||||||
*
|
*
|
||||||
|
|
@ -176,6 +217,7 @@ class SchneespurModuleClient
|
||||||
if (! is_file($this->stateFilePath)) {
|
if (! is_file($this->stateFilePath)) {
|
||||||
return [
|
return [
|
||||||
'catalog_etag' => null,
|
'catalog_etag' => null,
|
||||||
|
'catalog_cache' => null,
|
||||||
'synced_at' => null,
|
'synced_at' => null,
|
||||||
'installed' => [],
|
'installed' => [],
|
||||||
'orphans' => [],
|
'orphans' => [],
|
||||||
|
|
@ -194,6 +236,7 @@ class SchneespurModuleClient
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'catalog_etag' => $parsed['catalog_etag'] ?? null,
|
'catalog_etag' => $parsed['catalog_etag'] ?? null,
|
||||||
|
'catalog_cache' => $parsed['catalog_cache'] ?? null,
|
||||||
'synced_at' => $parsed['synced_at'] ?? null,
|
'synced_at' => $parsed['synced_at'] ?? null,
|
||||||
'installed' => $parsed['installed'] ?? [],
|
'installed' => $parsed['installed'] ?? [],
|
||||||
'orphans' => $parsed['orphans'] ?? [],
|
'orphans' => $parsed['orphans'] ?? [],
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,52 @@ class SchneespurModuleInstaller
|
||||||
}
|
}
|
||||||
|
|
||||||
File::ensureDirectoryExists($targetDir, 0755);
|
File::ensureDirectoryExists($targetDir, 0755);
|
||||||
|
|
||||||
|
$prefix = $this->detectCommonPrefix($zip);
|
||||||
|
|
||||||
|
if ($prefix === null) {
|
||||||
$zip->extractTo($targetDir);
|
$zip->extractTo($targetDir);
|
||||||
|
} else {
|
||||||
|
Log::info('schneespur-modules: stripping common prefix', [
|
||||||
|
'slug' => $slug,
|
||||||
|
'prefix' => $prefix,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$entry = $zip->getNameIndex($i);
|
||||||
|
if ($entry === false || $entry === '' || $entry === $prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($entry, '__MACOSX/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = substr($entry, strlen($prefix));
|
||||||
|
if ($relative === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dest = $targetDir . '/' . $relative;
|
||||||
|
|
||||||
|
if (str_ends_with($entry, '/')) {
|
||||||
|
File::ensureDirectoryExists($dest, 0755);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
File::ensureDirectoryExists(dirname($dest), 0755);
|
||||||
|
|
||||||
|
$contents = $zip->getFromIndex($i);
|
||||||
|
if ($contents === false || file_put_contents($dest, $contents) === false) {
|
||||||
|
Log::error('schneespur-modules: extract failed', [
|
||||||
|
'slug' => $slug,
|
||||||
|
'entry' => $entry,
|
||||||
|
]);
|
||||||
|
$zip->close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
Log::info('schneespur-modules: unpack complete', ['slug' => $slug]);
|
Log::info('schneespur-modules: unpack complete', ['slug' => $slug]);
|
||||||
|
|
@ -132,6 +177,41 @@ class SchneespurModuleInstaller
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether all (non-metadata) ZIP entries share one common
|
||||||
|
* top-level folder. Returns the prefix (incl. trailing slash) when so,
|
||||||
|
* null when the ZIP is flat or has mixed top-level entries.
|
||||||
|
*/
|
||||||
|
private function detectCommonPrefix(ZipArchive $zip): ?string
|
||||||
|
{
|
||||||
|
$prefix = null;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$entry = $zip->getNameIndex($i);
|
||||||
|
if ($entry === false || $entry === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($entry, '__MACOSX/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slash = strpos($entry, '/');
|
||||||
|
if ($slash === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$top = substr($entry, 0, $slash + 1);
|
||||||
|
|
||||||
|
if ($prefix === null) {
|
||||||
|
$prefix = $top;
|
||||||
|
} elseif ($prefix !== $top) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prefix;
|
||||||
|
}
|
||||||
|
|
||||||
private function validateZipEntries(ZipArchive $zip, string $slug): bool
|
private function validateZipEntries(ZipArchive $zip, string $slug): bool
|
||||||
{
|
{
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
|
|
||||||
|
|
@ -73,15 +73,17 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->reportable(function (\Throwable $e) {
|
$exceptions->reportable(function (\Throwable $e) {
|
||||||
|
$reported = false;
|
||||||
try {
|
try {
|
||||||
$manager = app(DiagnosticManager::class);
|
$manager = app(DiagnosticManager::class);
|
||||||
if ($manager->hasEnabledReporters()) {
|
if ($manager->hasEnabledReporters()) {
|
||||||
$manager->reportException($e);
|
$manager->reportException($e);
|
||||||
|
$reported = true;
|
||||||
}
|
}
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Never let diagnostic reporting break the application
|
// Never let diagnostic reporting break the application
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return $reported ? false : null;
|
||||||
});
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ return [
|
||||||
|
|
||||||
'name' => env('APP_NAME', 'Schneespur'),
|
'name' => env('APP_NAME', 'Schneespur'),
|
||||||
|
|
||||||
'version' => '1.0.0',
|
'version' => trim((string) @file_get_contents(dirname(__DIR__).'/VERSION')) ?: '1.0.0',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ return [
|
||||||
'field_access_notes' => 'Zufahrt / Zugang',
|
'field_access_notes' => 'Zufahrt / Zugang',
|
||||||
'field_auto_notify' => 'Automatische Benachrichtigung per E-Mail',
|
'field_auto_notify' => 'Automatische Benachrichtigung per E-Mail',
|
||||||
'field_notification_email' => 'Benachrichtigungs-E-Mail',
|
'field_notification_email' => 'Benachrichtigungs-E-Mail',
|
||||||
'field_notify_recipients' => 'Weitere Empfänger (kommagetrennt)',
|
'field_notify_recipients' => 'Benachrichtigungsempfänger',
|
||||||
|
'notify_recipients_customer' => 'Nur Kunde',
|
||||||
|
'notify_recipients_object' => 'Nur Objekt-Kontakt',
|
||||||
|
'notify_recipients_both' => 'Kunde und Objekt-Kontakt',
|
||||||
'price_unit_per_job' => 'Pro Einsatz',
|
'price_unit_per_job' => 'Pro Einsatz',
|
||||||
'price_unit_monthly' => 'Monatlich',
|
'price_unit_monthly' => 'Monatlich',
|
||||||
'price_unit_seasonal' => 'Saisonal',
|
'price_unit_seasonal' => 'Saisonal',
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ return [
|
||||||
'field_access_notes' => 'Access / Entrance',
|
'field_access_notes' => 'Access / Entrance',
|
||||||
'field_auto_notify' => 'Automatic email notification',
|
'field_auto_notify' => 'Automatic email notification',
|
||||||
'field_notification_email' => 'Notification Email',
|
'field_notification_email' => 'Notification Email',
|
||||||
'field_notify_recipients' => 'Additional recipients (comma-separated)',
|
'field_notify_recipients' => 'Notification recipients',
|
||||||
|
'notify_recipients_customer' => 'Customer only',
|
||||||
|
'notify_recipients_object' => 'Object contact only',
|
||||||
|
'notify_recipients_both' => 'Customer and object contact',
|
||||||
'price_unit_per_job' => 'Per Job',
|
'price_unit_per_job' => 'Per Job',
|
||||||
'price_unit_monthly' => 'Monthly',
|
'price_unit_monthly' => 'Monthly',
|
||||||
'price_unit_seasonal' => 'Seasonal',
|
'price_unit_seasonal' => 'Seasonal',
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,12 @@
|
||||||
|
|
||||||
<div x-show="autoNotify" x-transition>
|
<div x-show="autoNotify" x-transition>
|
||||||
<x-input-label for="notify_recipients" :value="__('customer_object.field_notify_recipients')" />
|
<x-input-label for="notify_recipients" :value="__('customer_object.field_notify_recipients')" />
|
||||||
<x-text-input id="notify_recipients" name="notify_recipients" type="text" class="mt-1 block w-full" :value="old('notify_recipients', $object->notify_recipients ?? '')" />
|
@php($notifyRecipients = old('notify_recipients', $object->notify_recipients ?? 'customer'))
|
||||||
|
<select id="notify_recipients" name="notify_recipients" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||||
|
<option value="customer" @selected($notifyRecipients === 'customer')>{{ __('customer_object.notify_recipients_customer') }}</option>
|
||||||
|
<option value="object" @selected($notifyRecipients === 'object')>{{ __('customer_object.notify_recipients_object') }}</option>
|
||||||
|
<option value="both" @selected($notifyRecipients === 'both')>{{ __('customer_object.notify_recipients_both') }}</option>
|
||||||
|
</select>
|
||||||
<x-input-error :messages="$errors->get('notify_recipients')" class="mt-2" />
|
<x-input-error :messages="$errors->get('notify_recipients')" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue