get(); return array_map(function (License $license) { $licenseData = ApiDataTransformer::snakeToCamel($license->toArray()); $licenseData['validationInfo'] = $this->buildValidationInfo($licenseData); return $licenseData; }, $licenses->all()); } /** * Display the specified resource. * * Retrieves a single license by its ID. * * @param int $id The ID of the license to retrieve * @return array */ public function single($id) { $license = License::findOrFail($id); $licenseData = ApiDataTransformer::snakeToCamel($license->toArray()); $licenseData['validationInfo'] = $this->buildValidationInfo($licenseData); return $licenseData; } /** * Display the specified resource. * * Renders the Inertia view for licenses * * @return \Inertia\Response */ public function show() { return Inertia::render( 'Licenses', ['licenseData' => $this->index()] ); } /** * Get the directory path for license keys. * * @return string */ protected function licenseKeyDirectory(): string { return 'license-keys'; } /** * Get the path for the private key file. * * @return string */ protected function privateKeyPath(): string { return $this->licenseKeyDirectory() . '/private.pem'; } /** * Get the path for the public key file. * * @return string */ protected function publicKeyPath(): string { return $this->licenseKeyDirectory() . '/public.pem'; } /** * Get the application's key pair. * * @return array */ protected function getApplicationKeyPair(): array { if (!Storage::disk('local')->exists($this->privateKeyPath()) || !Storage::disk('local')->exists($this->publicKeyPath())) { abort(500, 'Application license key pair is missing. Generate it with `php artisan license:generate-keys`.'); } return [ 'privateKey' => Storage::disk('local')->get($this->privateKeyPath()), 'publicKey' => Storage::disk('local')->get($this->publicKeyPath()), ]; } /** * Generate a signature for the license 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 * @return string */ protected function sign($licenseData, $privateKey) { $licenseJson = json_encode($licenseData); if ($licenseJson === false) throw new \Exception('Fehler beim JSON-Encode der Lizenzdaten: ' . json_last_error_msg()); $success = openssl_sign($licenseJson, $signature, $privateKey, OPENSSL_ALGO_SHA256); if ($success === false) throw new \Exception('Fehler beim Signieren der Lizenzdaten: ' . openssl_error_string()); return base64_encode($signature); } /** * Store a newly created resource in storage. * * Validates the request data, generates a signature, and creates a new license. * * @param \Illuminate\Http\Request $request The HTTP request containing license data * @return array */ public function store(Request $request) { // Validate the request data coming from the frontend in camelCase. $validatedData = $request->validate([ 'name' => 'required|string', 'email' => 'required|email', 'product' => 'required|string', 'version' => 'required|string', 'isPerpetual' => 'required|boolean', 'expirationDate' => 'nullable|date', ]); $keyPair = $this->getApplicationKeyPair(); // 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($signatureData, $keyPair['privateKey']); // Convert incoming camelCase fields to snake_case only for database storage. $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); $license = License::create([ 'name' => $snakeCaseData['name'], 'email' => $snakeCaseData['email'], 'product' => $snakeCaseData['product'], 'version' => $snakeCaseData['version'], 'is_perpetual' => $snakeCaseData['is_perpetual'], 'expiration_date' => $snakeCaseData['expiration_date'], 'signature' => $signature, ]); return ApiDataTransformer::snakeToCamel($license->toArray()); } /** * Validate the license signature and expiration date. * * @param \Illuminate\Http\Request $request The HTTP request containing the license data * @return array */ 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', 'product' => 'required|string', 'version' => 'required|string', 'isPerpetual' => 'required|boolean', 'expirationDate' => 'nullable|date', 'signature' => 'required|string', ]); return $this->buildValidationInfo($validatedData); } /** * Build the validation info for a license payload. * * 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 buildValidationInfo(array $licenseData): array { $publicKey = Storage::disk('local')->get($this->publicKeyPath()); $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 [ 'isValid' => $isSignatureValid && !$isExpired, 'isSignatureValid' => $isSignatureValid, 'isExpired' => $isExpired, ]; } /** * Download the license as a JSON file. * * @param int $id The ID of the license to download * @return \Symfony\Component\HttpFoundation\StreamedResponse */ public function download(int $id) { // Find the license $license = License::findOrFail($id); // Prepare the license data for download $licenseData = [ 'name' => $license->name, 'email' => $license->email, 'product' => $license->product, 'version' => $license->version, 'is_perpetual' => $license->is_perpetual, 'expiration_date' => $license->expiration_date?->format('Y-m-d'), 'signature' => $license->signature, ]; // 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, [ 'Content-Type' => 'application/json', 'Content-Disposition' => 'attachment; filename="' . $licenseData['product'] . '.license"', ]); 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. * * Validates the request data, generates a new signature, and updates the license. * * @param \Illuminate\Http\Request $request The HTTP request containing updated license data * @param int $id The ID of the license to update * @return array|\Illuminate\Http\JsonResponse */ public function update(Request $request, int $id) { // Validate the request data coming from the frontend in camelCase. $validatedData = $request->validate([ 'name' => 'required|string', 'email' => 'required|email', 'product' => 'required|string', 'version' => 'required|string', 'isPerpetual' => 'required|boolean', 'expirationDate' => 'nullable|date', ]); DB::beginTransaction(); try { $privateKey = $this->getApplicationKeyPair()['privateKey']; $licenseData = $this->persistLicenseUpdate($validatedData, $id, $privateKey); DB::commit(); // Return the updated license with its validation info attached. return $licenseData; } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'message' => 'Lizenz konnte nicht aktualisiert werden', 'error' => $e->getMessage() ], 500); } } /** * Remove the specified resource from storage. * * Deletes the specified license from the database. * * @param int $id The ID of the license to delete * @return \Illuminate\Http\JsonResponse */ public function destroy(int $id) { $license = License::findOrFail($id); $license->delete(); return response()->json([ 'message' => 'Lizenz erfolgreich gelöscht' ]); } }