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:
Michael 2026-05-19 12:44:23 +00:00
parent 7e27270626
commit 7e1222b022
10 changed files with 171 additions and 32 deletions

View file

@ -1 +1 @@
1.0.2 1.0.3

View file

@ -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(),

View file

@ -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'],
]; ];
} }

View file

@ -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'] ?? [],

View file

@ -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++) {

View file

@ -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();

View file

@ -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',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -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',

View file

@ -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',

View file

@ -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>