session = Session::getInstance($phpUnit); $this->client = Client::getParam(); if (!isset($this->client[DIRTY_RECORD_HASH_MD5])) { $this->client[DIRTY_RECORD_HASH_MD5] = ''; } $this->db = new Database(); } /** * Handle any lock requests submitted via api/dirty.php. * * @return array|int * @throws CodeException * @throws DbException * @throws UserFormException */ public function process() { $tableVars = array(); $sipClass = new Sip(); $sipVars = $sipClass->getVarsFromSip($this->client[SIP_SIP]); if (empty($sipVars[SIP_FORM])) { throw new CodeException("Missing 'form' in SIP. There might be something broken.", ERROR_DIRTY_MISSING_FORM_IN_SIP); } $recordId = empty($sipVars[SIP_RECORD_ID]) ? 0 : $sipVars[SIP_RECORD_ID]; if ($recordId == 0) { // For r=0 (new) , 'dirty' will always succeed. return [API_STATUS => 'success', API_MESSAGE => '']; } else { $tableVars = $this->db->sql("SELECT tableName, dirtyMode, recordLockTimeoutSeconds FROM Form AS f WHERE f.name=?", ROW_EXPECT_1, [$sipVars[SIP_FORM]], "Form not found: '" . $sipVars[SIP_FORM] . "'"); } switch ($this->client[API_LOCK_ACTION]) { case API_LOCK_ACTION_LOCK: case API_LOCK_ACTION_EXTEND: $answer = $this->acquireDirty($recordId, $tableVars, $this->client[DIRTY_RECORD_HASH_MD5]); break; case API_LOCK_ACTION_RELEASE: $answer = $this->checkDirtyAndRelease(FORM_SAVE, $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS], $tableVars[F_DIRTY_MODE], $tableVars[F_TABLE_NAME], $recordId); break; default; throw new CodeException("Unknown action: " . $this->client[API_LOCK_ACTION], ERROR_DIRTY_UNKNOWN_ACTION); } return $answer; } /** * Tries to get a 'DirtyRecord'. Returns an array (becomes JSON) about success or failure. * * @param int $recordId * @param array $tableVars * @param string $recordHashMd5 * @return array * @throws \qfq\CodeException */ private function acquireDirty($recordId, array $tableVars, $recordHashMd5) { $tableName = $tableVars[F_TABLE_NAME]; $formDirtyMode = $tableVars[F_DIRTY_MODE]; $rcMd5 = ''; // Check for changed record. Compute $rcMd5 $flagModified = $this->isRecordModified($tableName, $recordId, $recordHashMd5, $rcMd5); if (($recordHashMd5 != '') && $flagModified) { return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime. Please reload the form, edit and save again.']; } $feUser = $this->session->get(SESSION_FE_USER); // Look for already existing dirty record. $recordDirty = $this->getRecordDirty($tableName, $recordId); // Check if the record is timed out - owner doesn't matter. if (count($recordDirty) != 0 && $recordDirty[DIRTY_EXPIRE] < date('Y-m-d H:i:s')) { $this->deleteDirtyRecord($recordDirty[COLUMN_ID]); $recordDirty = array(); } if (count($recordDirty) == 0) { if ($formDirtyMode == DIRTY_MODE_NONE) { $answer = [API_STATUS => 'success', API_MESSAGE => '']; } else { // No dirty record found. $answer = $this->writeDirty($this->client[SIP_SIP], $recordId, $tableVars, $feUser, $rcMd5); } } else { $answer = $this->conflict($recordDirty, $formDirtyMode); } return $answer; } /** * Load (if exist) a DirtyRecord (lock). * * @param string $tableName * @param int $recordId * @return array DirtyRecord or empty array. * @throws CodeException * @throws DbException */ private function getRecordDirty($tableName, $recordId) { $recordDirty = $this->db->sql("SELECT * FROM Dirty AS d WHERE d.tableName LIKE ? AND recordId=? ", ROW_EXPECT_0_1, [$tableName, $recordId]); return $recordDirty; } /** * * @param array $recordDirty * @param string $currentFormDirtyMode * @return array */ private function conflict(array $recordDirty, $currentFormDirtyMode) { $status = API_ANSWER_STATUS_CONFLICT; $at = "at " . $recordDirty[COLUMN_CREATED] . " from " . $recordDirty[DIRTY_REMOTE_ADDRESS]; // Compare modified timestamp if ($this->isRecordModified($recordDirty[DIRTY_TABLE_NAME], $recordDirty[DIRTY_RECORD_ID], $recordDirty[DIRTY_RECORD_HASH_MD5], $dummy)) { return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime. Please reload the form, edit and save again.']; } if ($this->client[CLIENT_COOKIE_QFQ] == $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE]) { $msg = "The record has already been locked by you (maybe in another browser tab) $at!"; $status = ($recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE) ? API_ANSWER_STATUS_CONFLICT : API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE; } else { if (empty($recordDirty[DIRTY_FE_USER])) { $msgUser = "another user"; } else { $msgUser = "user '$recordDirty[DIRTY_FE_USER]'"; } $msg = "The record has already been locked by $msgUser at $at."; // Mandatory lock on Record or current Form? if ($recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE || $currentFormDirtyMode == DIRTY_MODE_EXCLUSIVE) { $status = API_ANSWER_STATUS_CONFLICT; } else { $status = API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE; } } return [API_STATUS => $status, API_MESSAGE => $msg]; } /** * Write a 'Dirty'-Record. * * @param string $s SIP given by URL GET * @param int $recordId extracted from SIP * @param array $tableVars columns: F_TABLE_NAME, F_DIRTY_MODE, F_RECORD_LOCK_TIMEOUT_SECONDS * @param string $feUser * @param string $recordHashMd5 * @return array * @throws \qfq\CodeException * @throws \qfq\DbException */ private function writeDirty($s, $recordId, array $tableVars, $feUser, $recordHashMd5) { $tableName = $tableVars[F_TABLE_NAME]; $formDirtyMode = $tableVars[F_DIRTY_MODE]; $record = $this->db->sql("SELECT * FROM $tableName WHERE id=?", ROW_EXPECT_1, [$recordId], "Record to lock not found."); $expire = date('Y-m-d H:i:s', strtotime("+" . $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS] . " seconds")); // Write 'dirty' record $this->db->sql("INSERT INTO Dirty (`sip`, `tableName`, `recordId`, `expire`, `recordHashMd5`, `feUser`, `qfqUserSessionCookie`, `dirtyMode`, `remoteAddress`, `created`) " . "VALUES ( ?,?,?,?,?,?,?,?,?,? )", ROW_REGULAR, [$s, $tableName, $recordId, $expire, $recordHashMd5, $feUser, $this->client[CLIENT_COOKIE_QFQ], $formDirtyMode, $this->client[CLIENT_REMOTE_ADDRESS], date('YmdHis')]); return [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => '', API_LOCK_TIMEOUT => $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS]]; } /** * Get MD5 from tableName/recordId and compare with $recordHashMd5. * * @param string $tableName * @param int $recordId * @param string $recordHashMd5 - timestamp e.g. '2017-07-27 14:06:56' * @return bool true if $recordHashMd5 is different from current record md5 hash. * @throws CodeException * @throws DbException */ private function isRecordModified($tableName, $recordId, $recordHashMd5, &$rcMd5) { if($recordHashMd5 =='') { return false; // If there is no recordHashMd5, the check is not possible. Always return 'not modified' (=ok) } $record = $this->db->sql("SELECT * FROM $tableName WHERE id=?", ROW_EXPECT_1, [$recordId], "Record to lock not found."); $rcMd5 = OnArray::getMd5($record); return ($recordHashMd5 != $rcMd5); } /** * Check if a lock exist for the current table, recordId and session. * * @param string $tableName * @param int $recordId * @param array $recordDirty - return dirty record if one exist. * @param string $msg - return preformatted message in case of conflict * @return int LOCK_NOT_FOUND | LOCK_FOUND_OWNER | LOCK_FOUND_CONFLICT, */ public function getCheckDirty($tableName, $recordId, array &$recordDirty, &$msg) { $msg = ''; if ($recordId == 0) { return LOCK_NOT_FOUND; // New records never have a recordDirty nor a conflict. } $recordDirty = $this->getRecordDirty($tableName, $recordId); if (empty($recordDirty)) { return LOCK_NOT_FOUND; } if ($recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE] == $this->client[CLIENT_COOKIE_QFQ]) { $msgUser = "you"; } else { $msgUser = (empty($recordDirty[DIRTY_FE_USER])) ? "another user" : "user '$recordDirty[DIRTY_FE_USER]'"; } $msgAt = "at " . $recordDirty[COLUMN_CREATED] . " from " . $recordDirty[DIRTY_REMOTE_ADDRESS]; $msg = "The record has been locked by $msgUser $msgAt"; // Is the dirtyRecord mine? if ($recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE] == $this->client[CLIENT_COOKIE_QFQ]) { return LOCK_FOUND_OWNER; } else { return LOCK_FOUND_CONFLICT; } } /** * Release a dirtyRecord. This is only possible if the current user owns the dirtyRecord. * In case of not owner, throws an exception and the save should break. * * @param string $formMode FORM_DELETE, FORM_SAVE * @param int $lockTimeout * @param string $dirtyMode DIRTY_MODE_EXCLUSIVE, DIRTY_MODE_ADVISORY, DIRTY_MODE_NONE * @param string $tableName * @param int $recordId * @return array * @throws \qfq\CodeException * @throws \qfq\UserFormException */ public function checkDirtyAndRelease($formMode, $lockTimeout, $dirtyMode, $tableName, $recordId, $flagCheckModifiedFirst = false) { $rcRecordDirty = array(); $rcMsg = ''; $answer = [API_STATUS => API_ANSWER_STATUS_SUCCESS, API_MESSAGE => '']; if ($recordId == 0) { return $answer; // New records never have a recordDirty nor a conflict. } // Check if the record has changed in the meantime. if ($flagCheckModifiedFirst && $this->isRecordModified($tableName, $recordId, $this->client[DIRTY_RECORD_HASH_MD5], $dummy)) { throw new UserFormException ('The record has been modified in the meantime. Please reload the form, edit and save again.', ERROR_DIRTY_RECORD_MODIFIED); // return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime. Please reload the form, edit and save again.']; } $lockStatus = $this->getCheckDirty($tableName, $recordId, $rcRecordDirty, $rcMsg); if (empty($rcRecordDirty)) { if ($dirtyMode == DIRTY_MODE_NONE) { return $answer; // only situation where it's ok that there is no dirtyRecord. } if ($formMode == FORM_DELETE) { return $answer; } // This is pessimistic, but secure. throw new UserFormException("Missing record lock: please reload the form, edit and save again.", ERROR_DIRTY_MISSING_LOCK); } if ($formMode == FORM_DELETE) { // Check if the record is timed out if ($lockTimeout > 0 && $rcRecordDirty[DIRTY_EXPIRE] < date('Y-m-d H:i:s')) { $this->deleteDirtyRecord($rcRecordDirty[COLUMN_ID]); return $answer; } $answer = [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $rcMsg]; return $answer; } // Is the dirtyRecord mine? if ($lockStatus == LOCK_FOUND_OWNER) { // Check if the record has changed in the meantime. if ($this->isRecordModified($tableName, $recordId, $rcRecordDirty[DIRTY_RECORD_HASH_MD5], $dummy)) { return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime. Please reload the form, edit and save again.']; } // Clear the lock $this->deleteDirtyRecord($rcRecordDirty[COLUMN_ID]); return $answer; } //---------------------------------------- // From here: there is a foreign lock! // Check if overwrite is allowed if ($dirtyMode == DIRTY_MODE_ADVISORY && $rcRecordDirty[F_DIRTY_MODE] == DIRTY_MODE_ADVISORY) { return $answer; } // Check if the record is timed out if ($lockTimeout > 0 && $rcRecordDirty[DIRTY_EXPIRE] < date('Y-m-d H:i:s')) { $this->deleteDirtyRecord($rcRecordDirty[COLUMN_ID]); return $answer; } throw new UserFormException($rcMsg, ERROR_DIRTY_ALREADY_LOCKED); } /** * Delete the dirtyRecord with $recordDirtyId. Throw an exception if the record has not been deleted. * * @param int $recordDirtyId * @throws CodeException * @throws DbException */ private function deleteDirtyRecord($recordDirtyId) { $cnt = $this->db->sql('DELETE FROM Dirty WHERE id=? LIMIT 1', ROW_REGULAR, [$recordDirtyId]); if ($cnt != 1) { throw new CodeException("Failed to delete dirty record id=" . $recordDirtyId, ERROR_DIRTY_DELETE_RECORD); } } }