[License module] Fix casing in licenses, allow multiple uploads properly handle dirty states in frontend
This commit is contained in:
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user