dolibarr 25.0.0-alpha
writeconfirmation.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Nick Fragoulis
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 3 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
34{
39
43 private $db;
44
48 public $error = '';
49
55 public function __construct($db)
56 {
57 $this->db = $db;
58 }
59
69 public function issue($user, $toolName, array $args, $preview)
70 {
71 global $conf;
72
73 $secret = $this->signingSecret();
74 if ($secret === '') {
75 $this->error = 'No instance secret available to sign the confirmation';
76
77 return '';
78 }
79
80 $state = dol_hash($secret.'|'.$toolName.'|'.$user->id.'|'.dol_now().'|'.microtime(true).'|'.mt_rand(), 'sha256');
81 $ttl = getDolGlobalInt('AI_WRITE_CONFIRMATION_TTL_MINUTES', self::DEFAULT_TTL_MINUTES);
82 if ($ttl <= 0) {
84 }
85
86 $sql = "INSERT INTO ".MAIN_DB_PREFIX."ai_write_confirmation";
87 $sql .= " (entity, state_hash, fk_user, tool_name, args_hash, preview, date_creation, date_expiration, ip)";
88 $sql .= " VALUES (".((int) $conf->entity);
89 $sql .= ", '".$this->db->escape($this->stateHash($state, $secret))."'";
90 $sql .= ", ".((int) $user->id);
91 $sql .= ", '".$this->db->escape($toolName)."'";
92 $sql .= ", '".$this->db->escape($this->argsHash($args))."'";
93 $sql .= ", '".$this->db->escape(dol_trunc((string) $preview, 60000, 'right', 'UTF-8', 1))."'";
94 $sql .= ", '".$this->db->idate(dol_now())."'";
95 $sql .= ", '".$this->db->idate(dol_now() + ($ttl * 60))."'";
96 $sql .= ", '".$this->db->escape(getUserRemoteIP())."'";
97 $sql .= ")";
98
99 if (!$this->db->query($sql)) {
100 $this->error = 'Cannot store the pending confirmation';
101 dol_syslog('[AiWriteConfirmation] '.$this->error.': '.$this->db->lasterror(), LOG_ERR);
102
103 return '';
104 }
105
106 // Keep the table bounded without depending on a scheduled job being
107 // enabled: one call in a hundred clears rows older than the retention.
108 if (mt_rand(1, 100) === 1) {
109 $this->purge(getDolGlobalInt('AI_WRITE_CONFIRMATION_KEEP_DAYS', 30));
110 }
111
112 return $state;
113 }
114
128 public function consume($user, $toolName, array $args, $state)
129 {
130 global $conf;
131
132 $this->error = '';
133 $secret = $this->signingSecret();
134 if ($secret === '' || !is_string($state) || $state === '') {
135 $this->error = 'Invalid confirmation';
136
137 return false;
138 }
139
140 $sql = "SELECT rowid, fk_user, tool_name, args_hash, date_expiration, date_consumed";
141 $sql .= " FROM ".MAIN_DB_PREFIX."ai_write_confirmation";
142 $sql .= " WHERE state_hash = '".$this->db->escape($this->stateHash($state, $secret))."'";
143 $sql .= " AND entity = ".((int) $conf->entity);
144
145 $resql = $this->db->query($sql);
146 if (!$resql || !($obj = $this->db->fetch_object($resql))) {
147 $this->error = 'Unknown or forged confirmation';
148 dol_syslog('[AiWriteConfirmation] rejected: no state matches', LOG_NOTICE);
149
150 return false;
151 }
152
153 if ((int) $obj->fk_user !== (int) $user->id) {
154 $this->error = 'This confirmation was issued to another user';
155 } elseif ((string) $obj->tool_name !== (string) $toolName) {
156 $this->error = 'This confirmation was issued for another action';
157 } elseif ((string) $obj->args_hash !== $this->argsHash($args)) {
158 $this->error = 'The action changed since it was confirmed';
159 } elseif (!empty($obj->date_consumed)) {
160 $this->error = 'This confirmation was already used';
161 } elseif ($this->db->jdate($obj->date_expiration) < dol_now()) {
162 $this->error = 'This confirmation expired, ask again';
163 }
164
165 if ($this->error !== '') {
166 dol_syslog('[AiWriteConfirmation] rejected for '.$toolName.': '.$this->error, LOG_NOTICE);
167
168 return false;
169 }
170
171 // Mark consumed before the write runs: a crash mid-write must not leave a
172 // state that can be replayed.
173 $sql = "UPDATE ".MAIN_DB_PREFIX."ai_write_confirmation";
174 $sql .= " SET date_consumed = '".$this->db->idate(dol_now())."'";
175 $sql .= " WHERE rowid = ".((int) $obj->rowid)." AND date_consumed IS NULL";
176
177 $resupdate = $this->db->query($sql);
178 // The WHERE includes date_consumed IS NULL, so two concurrent confirmations
179 // of the same state cannot both update the row: the loser gets 0 rows.
180 if (!$resupdate || $this->db->affected_rows($resupdate) === 0) {
181 $this->error = 'This confirmation was already used';
182 dol_syslog('[AiWriteConfirmation] rejected for '.$toolName.': concurrent use', LOG_NOTICE);
183
184 return false;
185 }
186
187 return true;
188 }
189
196 public function purge($keepdays = 30)
197 {
198 $sql = "DELETE FROM ".MAIN_DB_PREFIX."ai_write_confirmation";
199 $sql .= " WHERE date_expiration < '".$this->db->idate(dol_now() - ((int) $keepdays * 86400))."'";
200
201 $resql = $this->db->query($sql);
202 if (!$resql) {
203 return -1;
204 }
205
206 return (int) $this->db->affected_rows($resql);
207 }
208
219 private function signingSecret()
220 {
221 global $dolibarr_main_instance_unique_id, $dolibarr_main_cookie_cryptkey;
222
223 $instanceid = empty($dolibarr_main_instance_unique_id) ? (empty($dolibarr_main_cookie_cryptkey) ? '' : $dolibarr_main_cookie_cryptkey) : $dolibarr_main_instance_unique_id;
224 if (empty($instanceid)) {
225 return '';
226 }
227
228 return dol_hash('ai-write-confirmation'.$instanceid, 'sha256');
229 }
230
238 private function stateHash($state, $secret)
239 {
240 return dol_hash($secret.'|'.$state, 'sha256');
241 }
242
249 private function argsHash(array $args)
250 {
251 $normalized = $args;
252 unset($normalized['requestState']);
253 $this->ksortRecursive($normalized);
254
255 return dol_hash((string) json_encode($normalized), 'sha256');
256 }
257
264 private function ksortRecursive(array &$arr)
265 {
266 ksort($arr);
267 foreach ($arr as &$value) {
268 if (is_array($value)) {
269 $this->ksortRecursive($value);
270 }
271 }
272 }
273}
Class AiWriteConfirmation.
ksortRecursive(array &$arr)
Sort an array by key at every level, so argument order cannot change the hash.
argsHash(array $args)
Stable hash of the arguments a state was issued for.
stateHash($state, $secret)
Keyed hash of a state, so the database never holds the state itself.
signingSecret()
Secret used to sign states.
consume($user, $toolName, array $args, $state)
Validate a state presented on the confirming call, and consume it.
issue($user, $toolName, array $args, $preview)
Issue a state for a pending write.
const DEFAULT_TTL_MINUTES
Minutes a state stays valid, long enough to read a preview.
purge($keepdays=30)
Remove expired states that were never confirmed.
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
dol_now($mode='gmt')
Return date for now.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
getUserRemoteIP($trusted=0)
Return the real IP of remote user.
dol_trunc($string, $size=40, $trunc='right', $stringencoding='UTF-8', $nodot=0, $display=0)
Truncate a string to a particular length adding '...' if string larger than length.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
dol_hash($chain, $type='0', $nosalt=0, $mode=0)
Returns a hash (non reversible encryption) of a string.