Files
Caramel-CRM/app/Http/Controllers/LicenseController.php
T

425 lines
14 KiB
PHP
Raw Normal View History

2026-05-25 22:45:23 +02:00
<?php
namespace App\Http\Controllers;
use App\Models\License;
use App\Support\ApiDataTransformer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
class LicenseController extends Controller
{
/**
* Display a listing of the resource.
*
* Retrieves all licenses from the database, ordered by expiration date.
*
* @return array
*/
public function index()
{
$licenses = License::orderBy('expirationDate', 'asc')->get();
return array_map(function (License $license) {
$licenseData = ApiDataTransformer::snakeToCamel($license->toArray());
$licenseData['validationInfo'] = $this->buildValidationInfo($licenseData);
return $licenseData;
}, $licenses->all());
2026-05-25 22:45:23 +02:00
}
/**
* 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;
2026-05-25 22:45:23 +02:00
}
/**
* 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.
2026-05-25 22:45:23 +02:00
*
* @param array $licenseData The license data to sign
* @param string $privateKey The private key to use for signing
* @return string
*/
2026-07-10 16:55:48 +02:00
protected function sign($licenseData, $privateKey)
2026-05-25 22:45:23 +02:00
{
$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.
2026-05-25 22:45:23 +02:00
$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'],
2026-05-25 22:45:23 +02:00
];
$signature = $this->sign($signatureData, $keyPair['privateKey']);
// Convert incoming camelCase fields to snake_case only for database storage.
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
2026-05-25 22:45:23 +02:00
$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());
}
2026-07-10 16:55:48 +02:00
2026-05-25 22:45:23 +02:00
/**
* Validate the license signature and expiration date.
*
2026-07-10 16:55:48 +02:00
* @param \Illuminate\Http\Request $request The HTTP request containing the license data
2026-05-25 22:45:23 +02:00
* @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.
2026-07-10 16:55:48 +02:00
$validatedData = $request->validate([
'name' => 'required|string',
'email' => 'required|email',
'product' => 'required|string',
'version' => 'required|string',
'isPerpetual' => 'required|boolean',
'expirationDate' => 'nullable|date',
'signature' => 'required|string',
]);
2026-05-25 22:45:23 +02:00
return $this->buildValidationInfo($validatedData);
2026-07-10 16:55:48 +02:00
}
2026-05-25 22:45:23 +02:00
2026-07-10 16:55:48 +02:00
/**
* Build the validation info for a license payload.
2026-07-10 16:55:48 +02:00
*
* A license is considered valid when:
* - the signature is valid, and
* - the license is perpetual, or the expiration date is still in the future.
2026-07-10 16:55:48 +02:00
*
* @param array $licenseData The license data to validate
* @return array
*/
protected function buildValidationInfo(array $licenseData): array
2026-07-10 16:55:48 +02:00
{
$publicKey = Storage::disk('local')->get($this->publicKeyPath());
2026-05-25 22:45:23 +02:00
$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);
2026-05-25 22:45:23 +02:00
return [
'isValid' => $isSignatureValid && !$isExpired,
'isSignatureValid' => $isSignatureValid,
2026-07-10 16:55:48 +02:00
'isExpired' => $isExpired,
2026-05-25 22:45:23 +02:00
];
}
/**
* 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'),
2026-05-25 22:45:23 +02:00
'signature' => $license->signature,
];
// Normalize the outgoing payload to camelCase for the frontend consumer.
$licenseJson = json_encode(ApiDataTransformer::snakeToCamel($licenseData), JSON_PRETTY_PRINT);
2026-05-25 22:45:23 +02:00
// Create a response with the JSON file
$response = response($licenseJson, 200, [
'Content-Type' => 'application/json',
2026-07-10 16:55:48 +02:00
'Content-Disposition' => 'attachment; filename="' . $licenseData['product'] . '.license"',
2026-05-25 22:45:23 +02:00
]);
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);
}
}
2026-05-25 22:45:23 +02:00
/**
* 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.
2026-05-25 22:45:23 +02:00
$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);
2026-05-25 22:45:23 +02:00
DB::commit();
// Return the updated license with its validation info attached.
return $licenseData;
2026-05-25 22:45:23 +02:00
} 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'
]);
}
}