Refuse a key that does not belong to the slot it sits in
put('stripe.secret', 'sk_live_REALMONEY') in test mode was accepted,
get() handed it out, billing.stripe_secret reported satisfied, and the
badge above it said "Testbetrieb". The strict rule closed the automatic
route into that state (no fallback); the typed one stayed open.
The prefix decides it without touching the network, and that rule now
lives in ONE place — OperatingMode::ofStripeKey() — called by the three
that were answering it separately: the slot migration (unchanged verdict,
`?? Live` for a value it cannot place), StripeCheck's `live` flag
(unchanged verdict, null stays false), and the readiness check, which
never asked at all. A key it cannot place is not reported as a
contradiction: this check only says what it can prove.
The two directions get their own `breaks` sentence, because the
consequences are opposite — real money moving while the console says
test, versus no money moving while the order looks paid.
The "Prüfen" button no longer contradicts the check either: the page
rendered only ok/reason, so a live key in the test slot answered
"Geprüft: in Ordnung". It now names the account the key belongs to.
Red first:
⨯ it refuses a live key sitting in the test slot
⨯ it refuses a test key sitting in the live slot
⨯ it says what the wrong key does, not that a field is empty
Guard tests (ConfirmInModal, ModalHeight, IconLayout, DisplayTimezone)
run with the blade change: green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
parent
5094f70c19
commit
823eeaf413
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Services\Stripe;
|
namespace App\Services\Stripe;
|
||||||
|
|
||||||
use App\Services\Secrets\SecretVault;
|
use App\Services\Secrets\SecretVault;
|
||||||
|
use App\Support\OperatingMode;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
|
@ -50,7 +51,11 @@ final class StripeCheck
|
||||||
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
|
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
|
||||||
// Stripe does not label the key; the mode is inferred from the
|
// Stripe does not label the key; the mode is inferred from the
|
||||||
// prefix, which is the only thing that is true of both key kinds.
|
// prefix, which is the only thing that is true of both key kinds.
|
||||||
'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'),
|
// Asked of OperatingMode, not spelled out again here: the same
|
||||||
|
// question decides which slot the migration files a key into and
|
||||||
|
// whether the readiness page reports a key sitting in the wrong
|
||||||
|
// one. Unrecognisable (null) stays false, exactly as before.
|
||||||
|
'live' => OperatingMode::ofStripeKey($secret) === OperatingMode::Live,
|
||||||
'restricted' => str_starts_with($secret, 'rk_'),
|
'restricted' => str_starts_with($secret, 'rk_'),
|
||||||
'webhooks' => $this->webhooks($secret),
|
'webhooks' => $this->webhooks($secret),
|
||||||
];
|
];
|
||||||
|
|
@ -63,7 +68,7 @@ final class StripeCheck
|
||||||
* valid while the endpoint listens for the wrong five events, and nothing
|
* valid while the endpoint listens for the wrong five events, and nothing
|
||||||
* fails until a payment is not recorded.
|
* fails until a payment is not recorded.
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>>|null null when the key may not read them
|
* @return array<int, array<string, mixed>>|null null when the key may not read them
|
||||||
*/
|
*/
|
||||||
private function webhooks(string $secret): ?array
|
private function webhooks(string $secret): ?array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,37 @@ enum OperatingMode: string
|
||||||
Settings::set(self::SETTING, $mode->value);
|
Settings::set(self::SETTING, $mode->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zu welchem Modus ein Stripe-Schlüssel gehört — am Präfix, ohne Netzverkehr.
|
||||||
|
*
|
||||||
|
* Stripe beschriftet den Schlüssel nicht; das Präfix ist das Einzige, was
|
||||||
|
* bei beiden Schlüsselarten (`sk_`, und der eingeschränkte `rk_`) wahr ist.
|
||||||
|
* Das reicht, und es muss reichen: die Bereitschaftsseite darf beim
|
||||||
|
* Seitenaufruf nichts erreichen, und die Migration läuft, bevor irgendein
|
||||||
|
* Dienst antwortet.
|
||||||
|
*
|
||||||
|
* `null` heißt „nicht entscheidbar", nicht „live". Wer daraus eine
|
||||||
|
* Vorgabe braucht, sagt sie an seiner Stelle selbst — die Migration setzt
|
||||||
|
* `?? Live` (der Zustand vor ihr, also Fortsetzung statt neuer Behauptung),
|
||||||
|
* die Bereitschaftsprüfung meldet dagegen NUR einen Widerspruch, den sie
|
||||||
|
* beweisen kann.
|
||||||
|
*
|
||||||
|
* Diese eine Stelle, weil dieselbe Frage vorher an drei Orten unabhängig
|
||||||
|
* beantwortet wurde: in der Migration, in StripeCheck und — gar nicht — auf
|
||||||
|
* der Bereitschaftsseite. Drei Formulierungen einer Frage sind der Weg, auf
|
||||||
|
* dem sie auseinanderlaufen.
|
||||||
|
*/
|
||||||
|
public static function ofStripeKey(?string $secret): ?self
|
||||||
|
{
|
||||||
|
$secret = trim((string) $secret);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
str_starts_with($secret, 'sk_test_'), str_starts_with($secret, 'rk_test_') => self::Test,
|
||||||
|
str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_') => self::Live,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function isTest(): bool
|
public function isTest(): bool
|
||||||
{
|
{
|
||||||
return $this === self::Test;
|
return $this === self::Test;
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,33 @@ final class BillingChecks
|
||||||
{
|
{
|
||||||
$mode = OperatingMode::current();
|
$mode = OperatingMode::current();
|
||||||
|
|
||||||
|
// get() löst den aktiven Modus selbst auf UND ist für Stripe strikt —
|
||||||
|
// ein Live-Schlüssel im LIVE-Platz zählt im Testbetrieb nicht als
|
||||||
|
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
|
||||||
|
$secret = app(SecretVault::class)->get('stripe.secret');
|
||||||
|
|
||||||
|
// Was im Platz des aktiven Modus liegt, kann trotzdem der Schlüssel des
|
||||||
|
// anderen Kontos sein — eingetippt, nicht zurückgefallen. Am Präfix
|
||||||
|
// ohne Netzverkehr entscheidbar; `null` heißt „nicht entscheidbar" und
|
||||||
|
// wird hier bewusst NICHT als Widerspruch gemeldet: diese Prüfung sagt
|
||||||
|
// nur, was sie beweisen kann.
|
||||||
|
$keyMode = OperatingMode::ofStripeKey($secret);
|
||||||
|
$wrongSlot = $keyMode !== null && $keyMode !== $mode;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new Check(
|
new Check(
|
||||||
key: 'billing.stripe_secret',
|
key: 'billing.stripe_secret',
|
||||||
group: self::GROUP,
|
group: self::GROUP,
|
||||||
severity: Check::SEVERITY_BLOCKING,
|
severity: Check::SEVERITY_BLOCKING,
|
||||||
label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
|
label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
|
||||||
breaks: __('readiness.billing.stripe_secret_breaks'),
|
// Zwei Ausfälle, zwei Folgen — und „ohne ihn nimmt die Kasse
|
||||||
|
// keine Bestellung an" wäre über einen Schlüssel, der DA ist,
|
||||||
|
// schlicht falsch.
|
||||||
|
breaks: $wrongSlot
|
||||||
|
? __('readiness.billing.stripe_secret_'.$mode->value.'_slot_breaks')
|
||||||
|
: __('readiness.billing.stripe_secret_breaks'),
|
||||||
tab: 'services',
|
tab: 'services',
|
||||||
// get() löst den aktiven Modus selbst auf UND ist für Stripe
|
satisfied: filled($secret) && ! $wrongSlot,
|
||||||
// strikt — ein Live-Schlüssel zählt im Testbetrieb nicht als
|
|
||||||
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
|
|
||||||
satisfied: filled(app(SecretVault::class)->get('stripe.secret')),
|
|
||||||
),
|
),
|
||||||
new Check(
|
new Check(
|
||||||
key: 'billing.webhook_secret',
|
key: 'billing.webhook_secret',
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,12 @@ return new class extends Migration
|
||||||
return OperatingMode::Live;
|
return OperatingMode::Live;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live;
|
// Die Präfixregel steht in OperatingMode und wird hier gerufen, nicht
|
||||||
|
// wiederholt: dieselbe Frage entscheidet auf der Bereitschaftsseite, ob
|
||||||
|
// ein Schlüssel im falschen Platz liegt, und in StripeCheck, was der
|
||||||
|
// Konsole als Kontoart gemeldet wird. `?? Live` ist wie oben der
|
||||||
|
// Status quo für einen Wert, der sich nicht zuordnen lässt.
|
||||||
|
return OperatingMode::ofStripeKey($secret) ?? OperatingMode::Live;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ return [
|
||||||
'run_check' => 'Prüfen',
|
'run_check' => 'Prüfen',
|
||||||
'check_ok' => 'Geprüft: in Ordnung',
|
'check_ok' => 'Geprüft: in Ordnung',
|
||||||
'check_failed' => 'Geprüft: nicht in Ordnung',
|
'check_failed' => 'Geprüft: nicht in Ordnung',
|
||||||
|
'check_key_mode' => 'Der Schlüssel gehört zum Konto für :mode.',
|
||||||
|
'check_key_mode_conflict' => 'Diese Installation läuft aber im :mode — der Schlüssel liegt im falschen Platz.',
|
||||||
'fix_link' => 'Beheben',
|
'fix_link' => 'Beheben',
|
||||||
'last_heartbeat' => 'Letzter Herzschlag: :when',
|
'last_heartbeat' => 'Letzter Herzschlag: :when',
|
||||||
|
|
||||||
|
|
@ -40,6 +42,12 @@ return [
|
||||||
'billing' => [
|
'billing' => [
|
||||||
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
|
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
|
||||||
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
|
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
|
||||||
|
// Zwei getrennte Sätze, weil die Folgen entgegengesetzt sind: einmal
|
||||||
|
// fließt echtes Geld, das nicht fließen sollte, einmal fließt keines,
|
||||||
|
// das fließen sollte. Ein gemeinsamer Satz müsste beides andeuten und
|
||||||
|
// sagte am Ende keins von beidem.
|
||||||
|
'stripe_secret_test_slot_breaks' => 'Im Testplatz liegt ein Live-Schlüssel. Jede Bestellung bucht damit echtes Geld ab, während diese Seite und die Konsole „Testbetrieb" anzeigen — ein Probekauf ist dann ein echter Kauf, mit echter Karte und echter Abbuchung.',
|
||||||
|
'stripe_secret_live_slot_breaks' => 'Im Live-Platz liegt ein Testschlüssel. Der Kunde durchläuft die Kasse, es wird nie Geld eingezogen, und die Bestellung sieht trotzdem bezahlt aus — die Bereitstellung läuft, der Beleg wird ausgestellt, und auf dem Konto kommt nichts an.',
|
||||||
'webhook_secret' => 'Stripe-Signaturschlüssel',
|
'webhook_secret' => 'Stripe-Signaturschlüssel',
|
||||||
'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
|
'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
|
||||||
'company_details' => 'Firmendaten vollständig',
|
'company_details' => 'Firmendaten vollständig',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ return [
|
||||||
'run_check' => 'Check',
|
'run_check' => 'Check',
|
||||||
'check_ok' => 'Checked: OK',
|
'check_ok' => 'Checked: OK',
|
||||||
'check_failed' => 'Checked: not OK',
|
'check_failed' => 'Checked: not OK',
|
||||||
|
'check_key_mode' => 'This key belongs to the :mode account.',
|
||||||
|
'check_key_mode_conflict' => 'This installation is running in :mode — the key is in the wrong slot.',
|
||||||
'fix_link' => 'Fix',
|
'fix_link' => 'Fix',
|
||||||
'last_heartbeat' => 'Last heartbeat: :when',
|
'last_heartbeat' => 'Last heartbeat: :when',
|
||||||
|
|
||||||
|
|
@ -40,6 +42,10 @@ return [
|
||||||
'billing' => [
|
'billing' => [
|
||||||
'stripe_secret' => 'Stripe key (:mode)',
|
'stripe_secret' => 'Stripe key (:mode)',
|
||||||
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
|
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
|
||||||
|
// Two separate sentences, because the consequences are opposite ones:
|
||||||
|
// real money moving that should not, and no money moving that should.
|
||||||
|
'stripe_secret_test_slot_breaks' => 'The test slot holds a live key. Every order charges real money while this page and the console say "test mode" — a trial purchase is a real purchase, on a real card.',
|
||||||
|
'stripe_secret_live_slot_breaks' => 'The live slot holds a test key. The customer completes checkout, no money is ever taken, and the order still looks paid — provisioning runs, the invoice is issued, and nothing arrives in the account.',
|
||||||
'webhook_secret' => 'Stripe signing secret',
|
'webhook_secret' => 'Stripe signing secret',
|
||||||
'webhook_secret_breaks' => 'Without it a payment is never recorded: the customer pays, and nothing happens.',
|
'webhook_secret_breaks' => 'Without it a payment is never recorded: the customer pays, and nothing happens.',
|
||||||
'company_details' => 'Company details complete',
|
'company_details' => 'Company details complete',
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,22 @@
|
||||||
— <span class="font-mono">{{ $result['reason'] }}</span>
|
— <span class="font-mono">{{ $result['reason'] }}</span>
|
||||||
@endif
|
@endif
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{{-- StripeCheck rechnet die Kontoart des Schlüssels längst aus,
|
||||||
|
und diese Seite hat sie bislang weggeworfen: wer „Prüfen"
|
||||||
|
drückte, las „Geprüft: in Ordnung" über einen Live-Schlüssel
|
||||||
|
im Testplatz. Die Prüfung selbst blockiert dafür jetzt
|
||||||
|
(BillingChecks); hier steht, was der Knopf gerade gesehen
|
||||||
|
hat, damit er ihr nicht widerspricht. --}}
|
||||||
|
@if (array_key_exists('live', $result))
|
||||||
|
@php $keyMode = $result['live'] ? 'live' : 'test'; @endphp
|
||||||
|
<p class="mt-1 text-xs {{ $keyMode === $mode->value ? 'text-muted' : 'text-danger' }}">
|
||||||
|
{{ __('readiness.check_key_mode', ['mode' => __('readiness.mode.'.$keyMode)]) }}
|
||||||
|
@unless ($keyMode === $mode->value)
|
||||||
|
{{ __('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.'.$mode->value)]) }}
|
||||||
|
@endunless
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,55 @@ it('does not accept a live key as proof while the test mode is active', function
|
||||||
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Schlüssel im falschen Platz ist ein blockierender Befund.
|
||||||
|
*
|
||||||
|
* Gemessen im Schluss-Review: `put('stripe.secret', 'sk_live_REALMONEY')` im
|
||||||
|
* Testbetrieb wird angenommen, `get()` liefert ihn, die Bereitschaftsseite
|
||||||
|
* meldete „erfüllt" — und oben auf derselben Seite steht die Plakette
|
||||||
|
* „Testbetrieb". Das ist derselbe Ausgang, gegen den die strikte Ausnahme im
|
||||||
|
* Tresor gebaut wurde („bucht echtes Geld ab, während die Konsole Testbetrieb
|
||||||
|
* anzeigt"), nur über den eingetippten statt den automatischen Weg.
|
||||||
|
*
|
||||||
|
* Am Präfix ohne Netzverkehr entscheidbar — OperatingMode::ofStripeKey(),
|
||||||
|
* dieselbe Stelle, die auch die Migration und StripeCheck fragen.
|
||||||
|
*/
|
||||||
|
it('refuses a live key sitting in the test slot', function () {
|
||||||
|
OperatingMode::set(OperatingMode::Test);
|
||||||
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||||
|
|
||||||
|
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||||
|
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a test key sitting in the live slot', function () {
|
||||||
|
// Die andere Richtung kostet kein Geld, sondern nimmt keins ein: der Kunde
|
||||||
|
// zahlt in Stripes Testkonto, und die Bestellung sieht bezahlt aus.
|
||||||
|
OperatingMode::set(OperatingMode::Live);
|
||||||
|
app(SecretVault::class)->put('stripe.secret', 'sk_test_notreal', Operator::factory()->create(), OperatingMode::Live);
|
||||||
|
|
||||||
|
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the restricted key of the active mode', function () {
|
||||||
|
// rk_ ist ein echter Schlüssel, kein Sonderfall — die Erkennung darf ihn
|
||||||
|
// nicht für einen Widerspruch halten, nur weil er nicht sk_ heißt.
|
||||||
|
OperatingMode::set(OperatingMode::Test);
|
||||||
|
app(SecretVault::class)->put('stripe.secret', 'rk_test_readonly', Operator::factory()->create(), OperatingMode::Test);
|
||||||
|
|
||||||
|
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what the wrong key does, not that a field is empty', function () {
|
||||||
|
OperatingMode::set(OperatingMode::Test);
|
||||||
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||||
|
|
||||||
|
// Der Satz für „gar kein Schlüssel" wäre hier eine Lüge: einer liegt da,
|
||||||
|
// er ist der falsche, und die Folge ist eine ganz andere.
|
||||||
|
expect(checkFor('billing.stripe_secret')->breaks)
|
||||||
|
->not->toBe(__('readiness.billing.stripe_secret_breaks'));
|
||||||
|
});
|
||||||
|
|
||||||
it('reports incomplete company details without repeating the list', function () {
|
it('reports incomplete company details without repeating the list', function () {
|
||||||
Settings::forget('company.name');
|
Settings::forget('company.name');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use App\Livewire\Admin\Readiness as ReadinessPage;
|
use App\Livewire\Admin\Readiness as ReadinessPage;
|
||||||
use App\Models\Operator;
|
use App\Models\Operator;
|
||||||
use App\Services\Secrets\SecretVault;
|
use App\Services\Secrets\SecretVault;
|
||||||
|
use App\Support\OperatingMode;
|
||||||
use App\Support\Readiness;
|
use App\Support\Readiness;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
@ -47,6 +48,26 @@ it('runs an active check on demand and shows its answer', function () {
|
||||||
->assertSet('results.provisioning.dns_token.ok', false);
|
->assertSet('results.provisioning.dns_token.ok', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der „Prüfen"-Knopf darf der Prüfung nicht widersprechen.
|
||||||
|
*
|
||||||
|
* StripeCheck::run() rechnet die Kontoart des Schlüssels aus, die Seite hat
|
||||||
|
* sie verworfen und nur `ok`/`reason` gerendert — ein Live-Schlüssel im
|
||||||
|
* Testplatz antwortet bei Stripe einwandfrei, und die Seite las darüber
|
||||||
|
* „Geprüft: in Ordnung", während die Plakette oben „Testbetrieb" sagte.
|
||||||
|
*/
|
||||||
|
it('names the account a checked key belongs to, and says when it is the wrong one', function () {
|
||||||
|
Http::fake(['*' => Http::response(['id' => 'acct_1'], 200)]);
|
||||||
|
OperatingMode::set(OperatingMode::Test);
|
||||||
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
|
||||||
|
|
||||||
|
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
|
||||||
|
->test(ReadinessPage::class)
|
||||||
|
->call('runCheck', 'billing.stripe_secret')
|
||||||
|
->assertSee(__('readiness.check_key_mode', ['mode' => __('readiness.mode.live')]))
|
||||||
|
->assertSee(__('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.test')]));
|
||||||
|
});
|
||||||
|
|
||||||
it('refuses to run a check that is not on the list', function () {
|
it('refuses to run a check that is not on the list', function () {
|
||||||
// Ein Schlüssel aus dem Browser. Ohne diese Prüfung wäre das ein Weg,
|
// Ein Schlüssel aus dem Browser. Ohne diese Prüfung wäre das ein Weg,
|
||||||
// beliebige Klassen aufzurufen.
|
// beliebige Klassen aufzurufen.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue