Repository navigation
Expand file tree
/
Copy pathRecoveryDisposition.php
More file actions
343 lines (310 loc) · 12.1 KB
/
Copy pathRecoveryDisposition.php
File metadata and controls
343 lines (310 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
<?php
namespace USIPS\NCMEC;
use USIPS\NCMEC\Exception\CaseClosedToChanges;
use USIPS\NCMEC\Exception\IncidentAssociationBatchFailure;
/** Missing-at-review metadata settles bookkeeping, never source deletion. */
class RecoveryDisposition
{
private static array $activeIncidentAssociationTransactions = [];
public static function lock(\XF\App $app, int $caseId): bool
{
return (int)$app->db()->fetchOne('SELECT GET_LOCK(?, 0)', 'usipsNcmecCase' . $caseId) === 1;
}
public static function unlock(\XF\App $app, int $caseId): void
{
$app->db()->fetchOne('SELECT RELEASE_LOCK(?)', 'usipsNcmecCase' . $caseId);
}
/**
* Run a database operation in a transaction/savepoint and restore exactly
* the transaction depth owned by the caller if any Throwable escapes.
*/
public static function withTransaction(\XF\Db\AbstractAdapter $db, callable $callback)
{
$startedInTransaction = $db->inTransaction();
$startingSavepointDepth = self::savepointDepth($db);
$db->beginTransaction();
try
{
$result = $callback();
$db->commit();
return $result;
}
catch (\Throwable $e)
{
if (!self::rollbackToTransactionState($db, $startedInTransaction, $startingSavepointDepth))
{
throw new \RuntimeException(
'The NCMEC operation could not restore its caller transaction; the caller transaction was aborted.',
0,
$e
);
}
throw $e;
}
}
/**
* Keep the case and incident rows locked for the full association operation.
* A nested Creator/AttachmentManager call on the same incident reuses the
* caller's transaction and locks; the database savepoint stack remains owned
* by XenForo and each wrapper unwinds only its own level.
*/
public static function withIncidentAssociationTransaction(\XF\App $app, int $incidentId, callable $callback)
{
if ($incidentId < 1)
{
throw new \RuntimeException('An incident is required before NCMEC associations can be changed.');
}
$db = $app->db();
$lockKey = spl_object_id($db) . ':' . $incidentId;
if (!empty(self::$activeIncidentAssociationTransactions[$lockKey]))
{
return $callback();
}
$state = (object)['batchFailures' => []];
try
{
$result = self::withTransaction($db, function () use ($app, $incidentId, $callback, $lockKey, $state)
{
self::lockIncidentAssociationRows($app, $incidentId);
self::$activeIncidentAssociationTransactions[$lockKey] = $state;
try
{
try
{
return $callback();
}
catch (IncidentAssociationBatchFailure $e)
{
if ($e->getIncidentId() !== $incidentId)
{
throw $e;
}
// The explicit batch disposition keeps successful
// items, but unwinds composed work before reaching
// this outermost case transaction boundary.
$state->batchFailures[] = $e;
return null;
}
}
finally
{
unset(self::$activeIncidentAssociationTransactions[$lockKey]);
}
});
}
catch (\Throwable $e)
{
$state->batchFailures = [];
throw $e;
}
$batchFailures = $state->batchFailures;
$state->batchFailures = [];
if ($batchFailures)
{
$messages = array_values(array_unique(array_map(
static function (IncidentAssociationBatchFailure $failure): string
{
return $failure->getMessage();
},
$batchFailures
)));
throw new IncidentAssociationBatchFailure($incidentId, implode('; ', $messages));
}
return $result;
}
/**
* Called from entity source-write hooks after XenForo has opened its write
* transaction. The row locks then live through the actual save/delete commit.
*/
public static function assertIncidentAssociationOpen(\XF\App $app, int $incidentId): void
{
if ($incidentId < 1)
{
throw new \RuntimeException('The incident no longer exists; the evidence change was not applied.');
}
$lockKey = spl_object_id($app->db()) . ':' . $incidentId;
if (!empty(self::$activeIncidentAssociationTransactions[$lockKey]))
{
return;
}
self::lockIncidentAssociationRows($app, $incidentId);
}
/**
* Lock old and new cases in ascending order before the incident row, then
* verify the persisted mapping still matches the entity's original state.
*/
public static function lockIncidentCaseReassignment(
\XF\App $app,
int $incidentId,
int $expectedOldCaseId,
int $newCaseId,
bool $isInsert = false
): void
{
$db = $app->db();
if (!$isInsert)
{
$currentCaseId = (int)$db->fetchOne('SELECT case_id FROM xf_usips_ncmec_incident WHERE incident_id = ?', $incidentId);
if ($currentCaseId !== $expectedOldCaseId)
{
throw new \RuntimeException('The incident case assignment changed; retry the operation.');
}
$expectedOldCaseId = $currentCaseId;
}
$caseIds = array_values(array_unique(array_filter([$expectedOldCaseId, $newCaseId])));
sort($caseIds, SORT_NUMERIC);
foreach ($caseIds as $caseId)
{
self::lockOpenCaseRow($db, (int)$caseId);
}
if (!$isInsert)
{
$incident = $db->fetchRow('SELECT case_id FROM xf_usips_ncmec_incident WHERE incident_id = ? FOR UPDATE', $incidentId);
if (!$incident || (int)$incident['case_id'] !== $expectedOldCaseId)
{
throw new \RuntimeException('The incident case assignment changed; retry the operation.');
}
}
}
private static function lockIncidentAssociationRows(\XF\App $app, int $incidentId): void
{
$db = $app->db();
$incidentSnapshot = $db->fetchRow('SELECT case_id FROM xf_usips_ncmec_incident WHERE incident_id = ?', $incidentId);
if (!$incidentSnapshot)
{
throw new \RuntimeException('The incident no longer exists; the evidence change was not applied.');
}
$caseId = (int)$incidentSnapshot['case_id'];
if ($caseId > 0)
{
self::lockOpenCaseRow($db, $caseId);
}
$incident = $db->fetchRow('SELECT case_id FROM xf_usips_ncmec_incident WHERE incident_id = ? FOR UPDATE', $incidentId);
if (!$incident || (int)$incident['case_id'] !== $caseId)
{
throw new \RuntimeException('The incident case assignment changed; retry the evidence change.');
}
}
private static function lockOpenCaseRow(\XF\Db\AbstractAdapter $db, int $caseId): void
{
$case = $db->fetchRow('SELECT finalized_on, submitted_on
FROM xf_usips_ncmec_case WHERE case_id = ? FOR UPDATE', $caseId);
if (!$case)
{
throw new \RuntimeException('The NCMEC case no longer exists; the change was not applied.');
}
if ($case['finalized_on'] || $case['submitted_on'])
{
throw new CaseClosedToChanges();
}
}
/**
* Roll back exactly one transaction level: the one an entity delete()/save()
* opened around a failing source hook. Unlike rollbackAll() this leaves an
* unrelated outer caller transaction (and its savepoints) intact.
*/
public static function abortOwnedWriteLevel(\XF\Db\AbstractAdapter $db): void
{
try
{
$db->rollback();
}
catch (\Throwable $ignored)
{
// The initiating failure is the one worth reporting.
}
}
private static function savepointDepth(\XF\Db\AbstractAdapter $db): int
{
$property = new \ReflectionProperty(\XF\Db\AbstractAdapter::class, 'savePoints');
$property->setAccessible(true);
$savePoints = $property->getValue($db);
return is_array($savePoints) ? count($savePoints) : 0;
}
private static function rollbackToTransactionState(
\XF\Db\AbstractAdapter $db,
bool $startedInTransaction,
int $startingSavepointDepth
): bool
{
try
{
while ($db->inTransaction() && self::savepointDepth($db) > $startingSavepointDepth)
{
$db->rollback();
}
if (!$startedInTransaction && $db->inTransaction())
{
if (self::savepointDepth($db) !== 0)
{
throw new \LogicException('The transaction depth could not be restored.');
}
$db->rollback();
}
return $db->inTransaction() === $startedInTransaction
&& self::savepointDepth($db) === $startingSavepointDepth;
}
catch (\Throwable $rollbackError)
{
// A transaction-aborting database error may already have invalidated
// the caller's transaction. Clear the adapter rather than leak a
// transaction or replace the initiating failure with a rollback one.
try
{
$db->rollbackAll();
}
catch (\Throwable $ignored)
{
}
return false;
}
}
public static function records(\XF\App $app, int $reportId): array
{
$records = $app->db()->fetchAllColumn('SELECT decision.review_data FROM xf_usips_ncmec_recovery_log AS decision
INNER JOIN xf_usips_ncmec_report AS report ON report.report_id = decision.report_id
AND report.case_id = decision.case_id AND report.ncmec_report_id = decision.ncmec_report_id
WHERE decision.report_id = ? AND decision.action = ? AND report.submitted_on > 0
AND report.superseded_by_report_id IS NULL ORDER BY decision.recovery_id DESC LOCK IN SHARE MODE',
[$reportId, 'missing-review']);
return array_map(static fn($json) => json_decode($json, true, 512, JSON_THROW_ON_ERROR), $records);
}
public static function contentMissing(\XF\App $app, array $association, array $records): bool
{
if (!self::matches($association, 'missing_content', $records))
{
return false;
}
return ContentExport::sourceMissing($app, $association);
}
public static function attachmentMissing(\XF\App $app, array $association, array $records): bool
{
if (!self::matches($association, 'missing_attachments', $records))
{
return false;
}
return !$app->db()->fetchOne('SELECT data_id FROM xf_attachment_data WHERE data_id = ? LOCK IN SHARE MODE',
(int)$association['data_id']);
}
private static function matches(array $association, string $key, array $records): bool
{
$snapshot = ContentExport::fingerprint($association);
foreach ($records as $record)
{
if (!in_array((int)$association['user_id'], $record['subject_user_ids'] ?? [], true))
{
continue;
}
foreach ($record[$key] ?? [] as $missing)
{
if (($missing['source_status'] ?? '') === 'missing-at-review'
&& is_string($missing['association_snapshot'] ?? null)
&& hash_equals($missing['association_snapshot'], $snapshot))
{
return true;
}
}
}
return false;
}
}