diff --git a/app/Http/Controllers/LicenseController.php b/app/Http/Controllers/LicenseController.php index 6ef7ebb..a897556 100644 --- a/app/Http/Controllers/LicenseController.php +++ b/app/Http/Controllers/LicenseController.php @@ -21,7 +21,13 @@ class LicenseController extends Controller public function index() { $licenses = License::orderBy('expirationDate', 'asc')->get(); - return ApiDataTransformer::snakeToCamel($licenses->toArray()); + + return array_map(function (License $license) { + $licenseData = ApiDataTransformer::snakeToCamel($license->toArray()); + $licenseData['validationInfo'] = $this->buildValidationInfo($licenseData); + + return $licenseData; + }, $licenses->all()); } /** @@ -35,7 +41,10 @@ public function index() public function single($id) { $license = License::findOrFail($id); - return ApiDataTransformer::snakeToCamel($license->toArray()); + $licenseData = ApiDataTransformer::snakeToCamel($license->toArray()); + $licenseData['validationInfo'] = $this->buildValidationInfo($licenseData); + + return $licenseData; } /** @@ -103,7 +112,8 @@ protected function getApplicationKeyPair(): array /** * Generate a signature for the license data. * - * Uses the private key to sign the data. + * The payload is signed in camelCase because the signature is consumed by PHP-side + * validation logic and is not persisted as a database column shape. * * @param array $licenseData The license data to sign * @param string $privateKey The private key to use for signing @@ -130,7 +140,7 @@ protected function sign($licenseData, $privateKey) */ public function store(Request $request) { - // Validate the request data + // Validate the request data coming from the frontend in camelCase. $validatedData = $request->validate([ 'name' => 'required|string', 'email' => 'required|email', @@ -140,22 +150,24 @@ public function store(Request $request) 'expirationDate' => 'nullable|date', ]); - // Convert camelCase to snake_case - $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); - $keyPair = $this->getApplicationKeyPair(); - $licenseData = [ - 'name' => $snakeCaseData['name'], - 'email' => $snakeCaseData['email'], - 'product' => $snakeCaseData['product'], - 'version' => $snakeCaseData['version'], - 'is_perpetual' => $snakeCaseData['is_perpetual'], - 'expiration_date' => $snakeCaseData['expiration_date'], + // Sign the camelCase business payload. The signature is validated by PHP code, + // not persisted as part of the database column schema. + $signatureData = [ + 'name' => $validatedData['name'], + 'email' => $validatedData['email'], + 'product' => $validatedData['product'], + 'version' => $validatedData['version'], + 'isPerpetual' => $validatedData['isPerpetual'], + 'expirationDate' => $validatedData['isPerpetual'] ? null : $validatedData['expirationDate'], ]; - $signature = $this->sign($licenseData, $keyPair['privateKey']); + $signature = $this->sign($signatureData, $keyPair['privateKey']); + + // Convert incoming camelCase fields to snake_case only for database storage. + $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); $license = License::create([ 'name' => $snakeCaseData['name'], @@ -179,6 +191,8 @@ public function store(Request $request) */ public function validate(Request $request) { + // The frontend sends camelCase, and the signature is verified against the same + // camelCase representation. We keep snake_case only for database persistence. $validatedData = $request->validate([ 'name' => 'required|string', 'email' => 'required|email', @@ -189,42 +203,45 @@ public function validate(Request $request) 'signature' => 'required|string', ]); - return $this->isValid(ApiDataTransformer::camelToSnake($validatedData)); + return $this->buildValidationInfo($validatedData); } /** - * Validate the license signature and expiration date. + * Build the validation info for a license payload. * - * The validation logic should be portable to other languages. + * A license is considered valid when: + * - the signature is valid, and + * - the license is perpetual, or the expiration date is still in the future. * * @param array $licenseData The license data to validate * @return array */ - protected function isValid(array $licenseData) + protected function buildValidationInfo(array $licenseData): array { - $publicKey = Storage::disk('local')->get($this->publicKeyPath()); - - - // Convert the license data to JSON - $licenseJson = json_encode($licenseData); - - - // Verify the signature - $signature = base64_decode($licenseData['signature']); - $isSignatureValid = openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256); - // Check the expiration date if the license is not perpetual - $isExpired = false; - if (!$licenseData['is_perpetual'] && $licenseData['expiration_date']) { - $isExpired = now()->gt($licenseData['expiration_date']); - } + $licenseData = ApiDataTransformer::snakeToCamel($licenseData); + $isPerpetual = (bool) ($licenseData['isPerpetual'] ?? false); + $expirationDate = $licenseData['expirationDate'] ?? null; + + // Validate against the same camelCase payload shape used to generate the signature. + $licenseJson = json_encode([ + 'name' => $licenseData['name'] ?? '', + 'email' => $licenseData['email'] ?? '', + 'product' => $licenseData['product'] ?? '', + 'version' => $licenseData['version'] ?? '', + 'isPerpetual' => $isPerpetual, + 'expirationDate' => $expirationDate, + ]); + + $signature = base64_decode($licenseData['signature'] ?? '', true); + $isSignatureValid = $signature !== false && openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256) === 1; + $isExpired = !$isPerpetual && !empty($expirationDate) && now()->gt($expirationDate); - // Return the validation info return [ - 'isValid' => $isSignatureValid === 1 && !$isExpired, - 'isSignatureValid' => $isSignatureValid === 1, + 'isValid' => $isSignatureValid && !$isExpired, + 'isSignatureValid' => $isSignatureValid, 'isExpired' => $isExpired, ]; } @@ -247,12 +264,12 @@ public function download(int $id) 'product' => $license->product, 'version' => $license->version, 'is_perpetual' => $license->is_perpetual, - 'expiration_date' => $license->expiration_date, + 'expiration_date' => $license->expiration_date?->format('Y-m-d'), 'signature' => $license->signature, ]; - // Convert the license data to JSON - $licenseJson = json_encode($licenseData, JSON_PRETTY_PRINT); + // Normalize the outgoing payload to camelCase for the frontend consumer. + $licenseJson = json_encode(ApiDataTransformer::snakeToCamel($licenseData), JSON_PRETTY_PRINT); // Create a response with the JSON file $response = response($licenseJson, 200, [ @@ -263,6 +280,91 @@ public function download(int $id) return $response; } + /** + * Persist a single license update using the shared business logic. + * + * @param array $data The validated license payload in camelCase + * @param int $id The license id + * @param string $privateKey The application private key used to sign the payload + * @return array + */ + protected function persistLicenseUpdate(array $data, int $id, string $privateKey): array + { + $data['expirationDate'] = $data['isPerpetual'] ? null : $data['expirationDate']; + + $signatureData = [ + 'name' => $data['name'], + 'email' => $data['email'], + 'product' => $data['product'], + 'version' => $data['version'], + 'isPerpetual' => $data['isPerpetual'], + 'expirationDate' => $data['expirationDate'], + ]; + + $signature = $this->sign($signatureData, $privateKey); + $snakeCaseData = ApiDataTransformer::camelToSnake($data); + + $license = License::findOrFail($id); + $license->update([ + 'name' => $snakeCaseData['name'], + 'email' => $snakeCaseData['email'], + 'product' => $snakeCaseData['product'], + 'version' => $snakeCaseData['version'], + 'is_perpetual' => $snakeCaseData['is_perpetual'], + 'expiration_date' => $snakeCaseData['expiration_date'], + 'signature' => $signature, + ]); + + $licenseData = ApiDataTransformer::snakeToCamel($license->fresh()->toArray()); + $licenseData['validationInfo'] = $this->buildValidationInfo($licenseData); + + return $licenseData; + } + + /** + * Update multiple licenses in a single request. + * + * This keeps the database writes in one HTTP request and avoids SQLite lock contention + * that can happen when a page sends one update request per dirty license in parallel. + * + * @param \Illuminate\Http\Request $request The HTTP request containing multiple updated license records + * @return array|\Illuminate\Http\JsonResponse + */ + public function updateMany(Request $request) + { + $validatedData = $request->validate([ + '*.id' => 'required|integer', + '*.name' => 'required|string', + '*.email' => 'required|email', + '*.product' => 'required|string', + '*.version' => 'required|string', + '*.isPerpetual' => 'required|boolean', + '*.expirationDate' => 'nullable|date', + ]); + + $privateKey = $this->getApplicationKeyPair()['privateKey']; + + DB::beginTransaction(); + + try { + $updatedLicenses = []; + + foreach ($validatedData as $licensePayload) { + $updatedLicenses[] = $this->persistLicenseUpdate($licensePayload, $licensePayload['id'], $privateKey); + } + + DB::commit(); + + return $updatedLicenses; + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'message' => 'Lizenzen konnten nicht aktualisiert werden', + 'error' => $e->getMessage() + ], 500); + } + } + /** * Update the specified resource in storage. * @@ -274,7 +376,7 @@ public function download(int $id) */ public function update(Request $request, int $id) { - // Validate the request data + // Validate the request data coming from the frontend in camelCase. $validatedData = $request->validate([ 'name' => 'required|string', 'email' => 'required|email', @@ -284,46 +386,16 @@ public function update(Request $request, int $id) 'expirationDate' => 'nullable|date', ]); - // Convert camelCase to snake_case - $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); - DB::beginTransaction(); try { - // Find license - $license = License::findOrFail($id); - - // Retrieve the private key for the license $privateKey = $this->getApplicationKeyPair()['privateKey']; - - // Generate the license data - $licenseData = [ - 'name' => $snakeCaseData['name'], - 'email' => $snakeCaseData['email'], - 'product' => $snakeCaseData['product'], - 'version' => $snakeCaseData['version'], - 'is_perpetual' => $snakeCaseData['is_perpetual'], - 'expiration_date' => $snakeCaseData['expiration_date'], - ]; - - // Generate the signature - $signature = $this->sign($licenseData, $privateKey); - - // Update the license - $license->update([ - 'name' => $snakeCaseData['name'], - 'email' => $snakeCaseData['email'], - 'product' => $snakeCaseData['product'], - 'version' => $snakeCaseData['version'], - 'is_perpetual' => $snakeCaseData['is_perpetual'], - 'expiration_date' => $snakeCaseData['expiration_date'], - 'signature' => $signature, - ]); + $licenseData = $this->persistLicenseUpdate($validatedData, $id, $privateKey); DB::commit(); - // Return the updated license - return ApiDataTransformer::snakeToCamel($license->toArray()); + // Return the updated license with its validation info attached. + return $licenseData; } catch (\Exception $e) { DB::rollBack(); return response()->json([ diff --git a/database/migrations/2026_05_25_142250_create_licenses_table.php b/database/migrations/2026_05_25_142250_create_licenses_table.php index ed28ffa..cd77134 100644 --- a/database/migrations/2026_05_25_142250_create_licenses_table.php +++ b/database/migrations/2026_05_25_142250_create_licenses_table.php @@ -18,7 +18,7 @@ public function up(): void $table->string('product'); $table->string('version'); $table->boolean('is_perpetual'); - $table->string('expiration_date')->nullable();; + $table->string('expiration_date')->nullable(); $table->string('signature'); $table->timestamps(); }); diff --git a/resources/js/pages/Licenses.vue b/resources/js/pages/Licenses.vue index 2918aee..f6251e3 100644 --- a/resources/js/pages/Licenses.vue +++ b/resources/js/pages/Licenses.vue @@ -1,7 +1,7 @@