Skip to content

Commit b7612a2

Browse files
committed
FOUR-32319 Prevent publishing unchanged bundle versions
1 parent a969c75 commit b7612a2

12 files changed

Lines changed: 693 additions & 24 deletions

File tree

‎ProcessMaker/Http/Controllers/Api/DevLinkController.php‎

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Database\Eloquent\ModelNotFoundException;
66
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\DB;
78
use Illuminate\Support\Facades\File;
89
use Illuminate\Support\Facades\Notification;
910
use Illuminate\Validation\Rule;
@@ -23,6 +24,7 @@
2324
use ProcessMaker\Models\SettingsMenus;
2425
use ProcessMaker\Models\User;
2526
use ProcessMaker\Notifications\BundleUpdatedNotification;
27+
use ProcessMaker\Services\DevLink\BundleFingerprint;
2628

2729
class DevLinkController extends Controller
2830
{
@@ -146,14 +148,21 @@ public function remoteBundles(Request $request, DevLink $devLink)
146148
return $devLink->remoteBundles($request->input('filter'));
147149
}
148150

149-
public function createBundle(Request $request)
151+
public function createBundle(Request $request, BundleFingerprint $fingerprint)
150152
{
151-
$bundle = new Bundle();
152-
$bundle->name = $request->input('name');
153-
$bundle->description = $request->input('description');
154-
$bundle->published = (bool) $request->input('published', false);
155-
$bundle->version = 1;
156-
$bundle->saveOrFail();
153+
$bundle = DB::transaction(function () use ($request, $fingerprint) {
154+
$bundle = new Bundle();
155+
$bundle->name = $request->input('name');
156+
$bundle->description = $request->input('description');
157+
$bundle->published = (bool) $request->input('published', false);
158+
$bundle->version = 1;
159+
$bundle->saveOrFail();
160+
161+
$bundle->published_fingerprint = $fingerprint->calculate($bundle);
162+
$bundle->saveOrFail();
163+
164+
return $bundle;
165+
});
157166

158167
return $bundle;
159168
}
@@ -170,12 +179,34 @@ public function updateBundle(Request $request, Bundle $bundle)
170179
return $bundle;
171180
}
172181

173-
public function increaseBundleVersion(Bundle $bundle)
182+
public function increaseBundleVersion(Bundle $bundle, BundleFingerprint $fingerprint)
174183
{
175-
$bundle->notifyBundleUpdated();
184+
$bundle->validateEditable();
176185

177-
$bundle->version = $bundle->version + 1;
178-
$bundle->saveOrFail();
186+
$bundle = DB::transaction(function () use ($bundle, $fingerprint) {
187+
$lockedBundle = Bundle::whereKey($bundle->id)->lockForUpdate()->firstOrFail();
188+
$lockedBundle->validateEditable();
189+
$currentFingerprint = $fingerprint->calculate($lockedBundle);
190+
191+
// Legacy source bundles have no trustworthy published snapshot to backfill from.
192+
// Their first publication establishes the baseline; later identical attempts are blocked.
193+
if (
194+
$lockedBundle->published_fingerprint !== null
195+
&& hash_equals($lockedBundle->published_fingerprint, $currentFingerprint)
196+
) {
197+
throw ValidationException::withMessages([
198+
'*' => 'There are no changes to publish for this bundle.',
199+
]);
200+
}
201+
202+
$lockedBundle->version = $lockedBundle->version + 1;
203+
$lockedBundle->published_fingerprint = $currentFingerprint;
204+
$lockedBundle->saveOrFail();
205+
206+
return $lockedBundle;
207+
});
208+
209+
$bundle->notifyBundleUpdated();
179210

180211
return $bundle;
181212
}
@@ -202,10 +233,13 @@ public function bundleUpdated($bundleId, $token)
202233

203234
public function deleteBundle(Bundle $bundle)
204235
{
205-
$bundle->assets()->delete();
206-
$bundle->settings()->delete();
207-
$bundle->instances()->delete();
208-
$bundle->delete();
236+
DB::transaction(function () use ($bundle) {
237+
$lockedBundle = Bundle::whereKey($bundle->id)->lockForUpdate()->firstOrFail();
238+
$lockedBundle->assets()->delete();
239+
$lockedBundle->settings()->delete();
240+
$lockedBundle->instances()->delete();
241+
$lockedBundle->delete();
242+
});
209243
}
210244

211245
public function installRemoteBundle(Request $request, DevLink $devLink, $remoteBundleId)
@@ -378,14 +412,20 @@ public function remoteBundleVersion(DevLink $devLink, $remoteBundleId)
378412

379413
public function deleteBundleAsset(BundleAsset $bundleAsset)
380414
{
381-
$bundleAsset->delete();
415+
DB::transaction(function () use ($bundleAsset) {
416+
Bundle::whereKey($bundleAsset->bundle_id)->lockForUpdate()->firstOrFail();
417+
$bundleAsset->delete();
418+
});
382419

383420
return response()->json(['message' => 'Bundle asset association deleted.'], 200);
384421
}
385422

386423
public function deleteBundleSetting(BundleSetting $bundleSetting)
387424
{
388-
$bundleSetting->delete();
425+
DB::transaction(function () use ($bundleSetting) {
426+
Bundle::whereKey($bundleSetting->bundle_id)->lockForUpdate()->firstOrFail();
427+
$bundleSetting->delete();
428+
});
389429

390430
return response()->json(['message' => 'Bundle setting deleted.'], 200);
391431
}

‎ProcessMaker/Models/Bundle.php‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace ProcessMaker\Models;
44

55
use Illuminate\Database\Eloquent\Factories\HasFactory;
6+
use Illuminate\Support\Facades\DB;
67
use Illuminate\Support\Facades\Http;
78
use ProcessMaker\Exception\ExporterNotSupported;
89
use ProcessMaker\Exception\ValidationException;
@@ -105,6 +106,13 @@ public function exportSettingPayloads()
105106
}
106107

107108
public function syncAssets($assets)
109+
{
110+
return $this->mutateWithLock(function (Bundle $bundle) use ($assets) {
111+
return $bundle->syncAssetsWithoutLock($assets);
112+
});
113+
}
114+
115+
private function syncAssetsWithoutLock($assets)
108116
{
109117
$assetKeys = [];
110118
foreach ($assets as $asset) {
@@ -139,6 +147,13 @@ public function syncAssets($assets)
139147
}
140148

141149
public function addAsset(ProcessMakerModel $asset)
150+
{
151+
return $this->mutateWithLock(function (Bundle $bundle) use ($asset) {
152+
return $bundle->addAssetWithoutLock($asset);
153+
});
154+
}
155+
156+
private function addAssetWithoutLock(ProcessMakerModel $asset)
142157
{
143158
if (!BundleAsset::canExport($asset)) {
144159
throw new ExporterNotSupported();
@@ -159,6 +174,13 @@ public function addAsset(ProcessMakerModel $asset)
159174
}
160175

161176
public function addSettings($setting, $newId, $type = null, $replaceIds = false)
177+
{
178+
return $this->mutateWithLock(function (Bundle $bundle) use ($setting, $newId, $type, $replaceIds) {
179+
return $bundle->addSettingsWithoutLock($setting, $newId, $type, $replaceIds);
180+
});
181+
}
182+
183+
private function addSettingsWithoutLock($setting, $newId, $type = null, $replaceIds = false)
162184
{
163185
$existingSetting = $this->settings()->where('setting', $setting)->first();
164186

@@ -218,7 +240,7 @@ private function updateExistingSetting($existingSetting, $decodedNewId, $replace
218240
return;
219241
}
220242

221-
$config = json_decode($existingSetting->config, true) ?: ['id' => []];
243+
$config = $existingSetting->configAsArray() ?: ['id' => []];
222244

223245
if (!isset($config['id']) || !is_array($config['id'])) {
224246
$config['id'] = [];
@@ -374,6 +396,13 @@ public function savePayloadsToFile(array $payloads, array $payloadsSettings, $lo
374396
}
375397

376398
public function installSettings($settings)
399+
{
400+
return $this->mutateWithLock(function (Bundle $bundle) use ($settings) {
401+
return $bundle->installSettingsWithoutLock($settings);
402+
});
403+
}
404+
405+
private function installSettingsWithoutLock($settings)
377406
{
378407
$newSettingsKeys = collect($settings)->pluck('setting')->toArray();
379408

@@ -382,10 +411,23 @@ public function installSettings($settings)
382411
->delete();
383412

384413
foreach ($settings as $setting) {
385-
$this->addSettings($setting['setting'], $setting['config']);
414+
$this->addSettingsWithoutLock($setting['setting'], $setting['config']);
386415
}
387416
}
388417

418+
private function mutateWithLock(callable $callback)
419+
{
420+
$result = DB::transaction(function () use ($callback) {
421+
$bundle = static::whereKey($this->getKey())->lockForUpdate()->firstOrFail();
422+
423+
return $callback($bundle);
424+
});
425+
426+
$this->refresh();
427+
428+
return $result;
429+
}
430+
389431
public function installSettingsPayloads(array $payloads, $mode, $logger = null)
390432
{
391433
$options = new Options([

‎ProcessMaker/Models/BundleSetting.php‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,24 @@ public function bundle()
3636
return $this->belongsTo(Bundle::class);
3737
}
3838

39+
public function configAsArray(): array
40+
{
41+
if (is_array($this->config)) {
42+
return $this->config;
43+
}
44+
45+
if (!is_string($this->config)) {
46+
return [];
47+
}
48+
49+
$config = json_decode($this->config, true);
50+
51+
return is_array($config) ? $config : [];
52+
}
53+
3954
public function export()
4055
{
41-
$configData = json_decode($this->config, true);
56+
$configData = $this->configAsArray();
4257
$ids = $configData['id'] ?? [];
4358

4459
switch ($this->setting) {

0 commit comments

Comments
 (0)