diff --git a/htdocs/api/class/api_documents.class.php b/htdocs/api/class/api_documents.class.php index cd78a874ec48f..f22ca5395723d 100644 --- a/htdocs/api/class/api_documents.class.php +++ b/htdocs/api/class/api_documents.class.php @@ -781,6 +781,10 @@ public function post($filename, $modulepart, $ref = '', $subdir = '', $fileconte $modulepart = 'mrp'; require_once DOL_DOCUMENT_ROOT . '/mrp/class/mo.class.php'; $object = new Mo($this->db); + } elseif ($modulepart == 'ticket') { + $modulepart = 'ticket'; + require_once DOL_DOCUMENT_ROOT.'/ticket/class/ticket.class.php'; + $object = new Ticket($this->db); } else { // TODO Implement additional moduleparts throw new RestException(500, 'Modulepart '.$modulepart.' not implemented yet.'); @@ -1029,4 +1033,196 @@ public function delete($modulepart, $original_file) throw new RestException(403); } + + /** + * List documents for a module element + * + * @param string $modulepart Module part (ticket, invoice...) + * @param int $id ID of object + * @param string $ref Ref of object + * @return array List of documents + * + * @url GET /list + */ + public function listFiles(string $modulepart, int $id = 0, string $ref = ''): array { + + global $conf; + + // Include Files Lib + require_once DOL_DOCUMENT_ROOT . '/core/lib/files.lib.php'; + + // Validate inputs at API boundary before any modulepart-specific logic + $id = (int) $id; + $ref = trim($ref); + if (empty($modulepart)) { + throw new RestException(400, 'Parameter modulepart is required'); + } + if ($id <= 0 && empty($ref)) { + throw new RestException(400, 'Parameter id or ref is required'); + } + + $upload_dir = ''; + if ($modulepart == 'ticket') { + if (!DolibarrApiAccess::$user->hasRight('ticket', 'read')) { + throw new RestException(403, 'Missing permission to read ticket documents'); + } + + require_once DOL_DOCUMENT_ROOT . '/ticket/class/ticket.class.php'; + $object = new Ticket($this->db); + + if ($object->fetch($id, $ref) <= 0) { + throw new RestException(404, 'Ticket not found'); + } + + // External user: can only access tickets linked to their company + if (DolibarrApiAccess::$user->socid > 0 && $object->fk_soc != DolibarrApiAccess::$user->socid) { + throw new RestException(403, 'Access to this ticket is forbidden'); + } + + // Internal user with restricted view: can only access tickets assigned to them + if (!DolibarrApiAccess::$user->socid + && getDolGlobalString('TICKET_LIMIT_VIEW_ASSIGNED_ONLY') + && $object->fk_user_assign != DolibarrApiAccess::$user->id + && !DolibarrApiAccess::$user->hasRight('ticket', 'manage') + ) { + throw new RestException(403, 'Access to this ticket is forbidden'); + } + + $upload_dir = $conf->ticket->dir_output . "/" . dol_sanitizeFileName($object->ref); + } else { + throw new RestException(501, 'modulepart '.$modulepart.' is not supported by this endpoint'); + } + if ($upload_dir && dol_is_dir($upload_dir)) { + $filelist = dol_dir_list($upload_dir, 'files', 0, '', '(\.meta|_preview.*\.png)$'); + $res = array(); + foreach($filelist as $file) { + $res[] = array( + 'filename' => $file['name'], + 'size' => $file['size'], + 'date' => $file['date'], + 'type' => dol_mimetype($file['name']), + 'relativename' => $file['name'] + ); + } + return $res; + } + return array(); // Retourne vide si pas de dossier ou pas de fichiers + } + + /** + * Upload file for Ticket (Custom AskDoli) + * + * @param string $filename Filename + * @param string $ref Ticket Ref + * @param string $content Base64 Content + * @return string Saved filename + * + * @url POST /upload/ticket + */ + public function uploadTicketFile(string $filename, string $ref, string $content): string { + // Check permissions early + if (!DolibarrApiAccess::$user->hasRight('ticket', 'write')) { + throw new RestException(403, 'Missing permission to write ticket documents'); + } + + // Check that file upload is enabled and enforce size limit (MAIN_UPLOAD_DOC is in KB) + $maxUploadKb = getDolGlobalInt('MAIN_UPLOAD_DOC'); + if ($maxUploadKb <= 0) { + throw new RestException(403, 'File upload is disabled on this server'); + } + + // Validate base64 content and check decoded size before writing + $decodedContent = base64_decode($content, true); + if ($decodedContent === false) { + throw new RestException(400, 'Invalid base64 content'); + } + if (strlen($decodedContent) > $maxUploadKb * 1024) { + throw new RestException(400, 'File size exceeds the allowed limit of '.$maxUploadKb.' KB'); + } + + // Delegate to post() which handles virus scan, .noexe renaming, path traversal checks + // and dol_check_secure_access_document for the full security pipeline + try { + return $this->post($filename, 'ticket', $ref, '', $content, 'base64', 1, 1); + } catch (RestException $e) { + // Re-throw as-is: post() already produces meaningful RestExceptions + throw $e; + } catch (\Exception $e) { + dol_syslog('uploadTicketFile unexpected error ref='.$ref.' file='.$filename.': '.$e->getMessage(), LOG_ERR); + throw new RestException(500, 'Unexpected error while uploading file'); + } + } + + /** + * Download ticket file (Custom AskDoli) + * + * @param string $ref Ticket Ref + * @param string $filename Filename + * @return array File content structure + * + * @url GET /download/ticket + */ + public function downloadTicketFile(string $ref, string $filename): array { + global $conf, $db; + require_once DOL_DOCUMENT_ROOT . '/ticket/class/ticket.class.php'; + + // Check global permission + if (!DolibarrApiAccess::$user->hasRight('ticket', 'read')) { + throw new RestException(403, 'Missing permission to read ticket documents'); + } + + // Fetch the ticket to apply per-object access restrictions + $object = new Ticket($db); + if ($object->fetch(0, $ref) <= 0) { + throw new RestException(404, 'Ticket not found'); + } + + // External user: can only access tickets linked to their company + if (DolibarrApiAccess::$user->socid > 0 && $object->fk_soc != DolibarrApiAccess::$user->socid) { + throw new RestException(403, 'Access to this ticket is forbidden'); + } + + // Internal user with restricted view: can only access tickets assigned to them + if (!DolibarrApiAccess::$user->socid + && getDolGlobalString('TICKET_LIMIT_VIEW_ASSIGNED_ONLY') + && $object->fk_user_assign != DolibarrApiAccess::$user->id + && !DolibarrApiAccess::$user->hasRight('ticket', 'manage') + ) { + throw new RestException(403, 'Access to this ticket is forbidden'); + } + + // Build path using the canonical ref from DB, not from user input + $ticket_dir = $conf->ticket->dir_output . '/' . dol_sanitizeFileName($object->ref); + $file_path = $ticket_dir . '/' . dol_sanitizeFileName($filename); + + // Path traversal protection: block .. and shell metacharacters (same checks as index()) + if (preg_match('/\.\./', $file_path) || preg_match('/[<>|]/', $file_path)) { + dol_syslog('Path traversal attempt blocked: '.$file_path, LOG_WARNING); + throw new RestException(403, 'Access denied'); + } + + $file_path_os = dol_osencode($file_path); + + if (!file_exists($file_path_os)) { + dol_syslog('Try to download not found file '.$file_path_os, LOG_WARNING); + throw new RestException(404, 'File not found'); + } + + // Confirm the resolved real path is strictly inside the ticket directory (symlink / race-condition guard) + $real_dir = realpath(dol_osencode($ticket_dir)); + $real_file = realpath($file_path_os); + if ($real_dir === false || $real_file === false || strpos($real_file, $real_dir . DIRECTORY_SEPARATOR) !== 0) { + dol_syslog('File outside expected directory blocked: '.$file_path_os, LOG_WARNING); + throw new RestException(403, 'Access denied'); + } + + $content = file_get_contents($real_file); + return array( + 'filename' => basename($real_file), + 'content-type' => dol_mimetype($filename), + 'content' => base64_encode($content), + 'encoding' => 'base64' + ); + } + } diff --git a/htdocs/comm/action/class/api_agendaevents.class.php b/htdocs/comm/action/class/api_agendaevents.class.php index d2dd06c919cc2..8579b6d0bfac1 100644 --- a/htdocs/comm/action/class/api_agendaevents.class.php +++ b/htdocs/comm/action/class/api_agendaevents.class.php @@ -20,7 +20,7 @@ use Luracast\Restler\RestException; require_once DOL_DOCUMENT_ROOT.'/comm/action/class/actioncomm.class.php'; - +require_once DOL_DOCUMENT_ROOT . '/user/class/user.class.php'; /** * API class for Agenda Events @@ -202,6 +202,34 @@ public function index($sortfield = "t.id", $sortorder = 'ASC', $limit = 100, $pa ]; } + $user_cache = array(); + + // Fix: Loop through obj_ret instead of SQL result + $data_to_enrich = $pagination_data ? $obj_ret['data'] : $obj_ret; + foreach ($data_to_enrich as $key => $event) { + $author_id = property_exists($event, 'authorid') && $event->authorid ? $event->authorid : (property_exists($event, 'userownerid') ? $event->userownerid : 0); + + if ($author_id > 0) { + if (!isset($user_cache[$author_id])) { + $tmp_user = new User($db); + $tmp_user->fetch($author_id); + $user_cache[$author_id] = $tmp_user; + } + + // On injecte les infos dans l'objet retourné + $data_to_enrich[$key]->user_author_firstname = $user_cache[$author_id]->firstname; + $data_to_enrich[$key]->user_author_lastname = $user_cache[$author_id]->lastname; + // Use simple concatenation instead of getFullName to avoid langs dependency + $data_to_enrich[$key]->user_author_name = trim($user_cache[$author_id]->firstname . ' ' . $user_cache[$author_id]->lastname); + } + } + // Update the enriched data back + if ($pagination_data) { + $obj_ret['data'] = $data_to_enrich; + } else { + $obj_ret = $data_to_enrich; + } + return $obj_ret; } diff --git a/htdocs/core/lib/files.lib.php b/htdocs/core/lib/files.lib.php index 8d82ea46f9fee..39c354319416e 100644 --- a/htdocs/core/lib/files.lib.php +++ b/htdocs/core/lib/files.lib.php @@ -3524,6 +3524,25 @@ function dol_check_secure_access_document($modulepart, $original_file, $entity, $accessallowed = 1; } $original_file = $conf->member->dir_output.'/'.$original_file; + } elseif ($modulepart == 'ticket' && !empty($conf->ticket->dir_output)) { + // Wrapping for tickets + if ($fuser->hasRight('ticket', $read) || preg_match('/^specimen/i', $original_file)) { + $accessallowed = 1; + + // Per-ticket check for TICKET_LIMIT_VIEW_ASSIGNED_ONLY (internal users only) + if ($accessallowed && !$fuser->socid && getDolGlobalString('TICKET_LIMIT_VIEW_ASSIGNED_ONLY') && !$fuser->hasRight('ticket', 'manage')) { + if (!empty($refname) && !preg_match('/^specimen/i', $original_file)) { + include_once DOL_DOCUMENT_ROOT.'/ticket/class/ticket.class.php'; + $tmpticket = new Ticket($db); + if ($tmpticket->fetch(0, $refname) > 0 && $tmpticket->fk_user_assign != $fuser->id) { + $accessallowed = 0; + } + } + } + } + $original_file = $conf->ticket->dir_output.'/'.$original_file; + // External user check: ticket may or may not be linked to a thirdparty + $sqlprotectagainstexternals = "SELECT fk_soc as fk_soc FROM ".MAIN_DB_PREFIX."ticket WHERE ref='".$db->escape($refname)."' AND entity IN (".getEntity('ticket').")"; // If modulepart=module_user_temp Allows any module to open a file if file is in directory called DOL_DATA_ROOT/modulepart/temp/iduser // If modulepart=module_temp Allows any module to open a file if file is in directory called DOL_DATA_ROOT/modulepart/temp // If modulepart=module_user Allows any module to open a file if file is in directory called DOL_DATA_ROOT/modulepart/iduser diff --git a/htdocs/core/lib/ticket.lib.php b/htdocs/core/lib/ticket.lib.php index 2ed7811961e46..70598a2979a4e 100644 --- a/htdocs/core/lib/ticket.lib.php +++ b/htdocs/core/lib/ticket.lib.php @@ -132,6 +132,15 @@ function ticket_prepare_head($object) // History + $nbEvents = 0; + $sql = "SELECT COUNT(id) FROM " . $db->prefix() . "actioncomm"; + $sql .= " WHERE fk_element = " . (int) $object->id . " AND elementtype = 'ticket'"; + $resql = $db->query($sql); + if ($resql) { + $row = $db->fetch_row($resql); + $nbEvents = $row[0]; + } + $ticketViewType = "messaging"; if (empty($_SESSION['ticket-view-type'])) { $_SESSION['ticket-view-type'] = $ticketViewType; @@ -140,16 +149,19 @@ function ticket_prepare_head($object) } if ($ticketViewType == "messaging") { - $head[$h][0] = DOL_URL_ROOT.'/ticket/messaging.php?track_id='.$object->track_id; + $head[$h][0] = DOL_URL_ROOT . '/ticket/messaging.php?track_id=' . $object->track_id; } else { // $ticketViewType == "list" - $head[$h][0] = DOL_URL_ROOT.'/ticket/agenda.php?track_id='.$object->track_id; + $head[$h][0] = DOL_URL_ROOT . '/ticket/agenda.php?track_id=' . $object->track_id; } $head[$h][1] = $langs->trans('Events'); if (isModEnabled('agenda') && ($user->hasRight('agenda', 'myactions', 'read') || $user->hasRight('agenda', 'allactions', 'read'))) { $head[$h][1] .= '/'; $head[$h][1] .= $langs->trans("Agenda"); } + if ($nbEvents > 0) { + $head[$h][1] .= '' . $nbEvents . ''; + } $head[$h][2] = 'tabTicketLogs'; $h++;