FOSSology  4.7.1
Open Source License Compliance by Open Source Software
AdminCustomTextList.php
1 <?php
2 /*
3  SPDX-FileCopyrightText: © 2025 Harshit Gandhi <gandhiharshit716@gmail.com>
4  SPDX-FileCopyrightText: © Fossology contributors
5 
6  SPDX-License-Identifier: GPL-2.0-only
7 */
8 
9 namespace Fossology\UI\Page;
10 
15 use Symfony\Component\HttpFoundation\JsonResponse;
16 use Symfony\Component\HttpFoundation\Request;
17 use Symfony\Component\HttpFoundation\Response;
18 
20 {
21  const NAME = "admin_custom_text_list";
22 
23  function __construct()
24  {
25  parent::__construct(self::NAME, array(
26  self::TITLE => "Custom Text List",
27  self::MENU_LIST => "Admin::Text Management::List",
28  self::REQUIRES_LOGIN => true,
29  self::PERMISSION => Auth::PERM_ADMIN
30  ));
31  }
32 
38  protected function handle(Request $request)
39  {
40  $userId = Auth::getUserId();
41  $groupId = Auth::getGroupId();
42 
43  // Check if user is admin
44  if (!Auth::isAdmin()) {
45  return $this->flushContent(_('Access denied. Admin privileges required.'));
46  }
47 
48  $action = $request->get('action');
49 
50  // Handle AJAX requests
51  if ($action == 'fetchData') {
52  return $this->fetchDataServerSide($request);
53  }
54 
55  if ($action == 'get_bulk_data') {
56  return $this->getBulkDataAjax($request);
57  }
58 
59  if ($action == 'delete' && $request->getMethod() == 'POST') {
60  return $this->deletePhraseAjax($request);
61  }
62 
63  if ($action == 'toggle' && $request->getMethod() == 'POST') {
64  return $this->togglePhraseStatusAjax($request);
65  }
66 
67  // Default to table view
68  $vars = array(
69  'formAction' => Traceback_uri() . '?mod=' . self::NAME,
70  'addModuleName' => 'admin_custom_text_management'
71  );
72  return $this->render('admin_custom_text_list.html.twig', $this->mergeWithDefault($vars));
73  }
74 
78  private function fetchDataServerSide(Request $request)
79  {
80  $offset = intval($request->query->get('start', 0));
81  $limit = intval($request->query->get('length', 10));
82  $draw = intval($request->query->get('draw', 1));
83  $searchQuery = $request->query->all('search')['value'] ?? '';
84 
85  $filteredQuery = '';
86  if (!empty($searchQuery)) {
87  $filteredQuery = '%' . $searchQuery . '%';
88  }
89 
90  $totalCount = $this->getTotalPhrasesCount('');
91  $filteredCount = empty($filteredQuery) ? $totalCount : $this->getTotalPhrasesCount($filteredQuery);
92  $phraseArray = $this->getPhrasesServerSide($limit, $offset, $filteredQuery);
93 
94  return new JsonResponse([
95  "draw" => $draw,
96  "recordsTotal" => $totalCount,
97  "recordsFiltered" => $filteredCount,
98  "data" => $phraseArray,
99  ], JsonResponse::HTTP_OK);
100  }
101 
105  private function deletePhraseAjax(Request $request)
106  {
107  $phraseId = intval($request->get('id'));
108 
109  if (!$phraseId) {
110  return new JsonResponse(array('error' => 'Invalid phrase ID'), 400);
111  }
112 
113  try {
115  $dbManager = $this->getObject('db.manager');
116 
117  // Start transaction
118  $dbManager->begin();
119 
120  // Delete license associations first (though CASCADE should handle this)
121  $deleteLicensesSql = "DELETE FROM custom_phrase_license_map WHERE cp_fk = $1";
122  $dbManager->prepare($deleteLicensesStmt = __METHOD__ . ".delete_licenses", $deleteLicensesSql);
123  $dbManager->freeResult($dbManager->execute($deleteLicensesStmt, array($phraseId)));
124 
125  // Delete the phrase itself
126  $sql = "DELETE FROM custom_phrase WHERE cp_pk = $1";
127  $dbManager->prepare($stmt = __METHOD__ . ".delete", $sql);
128  $dbManager->freeResult($dbManager->execute($stmt, array($phraseId)));
129 
130  // Commit transaction
131  $dbManager->commit();
132 
133  return new JsonResponse(array(
134  'success' => true,
135  'message' => 'Custom text deleted successfully'
136  ));
137  } catch (\Exception $e) {
138  $dbManager->rollback();
139  return new JsonResponse(array('error' => 'Failed to delete phrase: ' . $e->getMessage()), 500);
140  }
141  }
142 
146  private function togglePhraseStatusAjax(Request $request)
147  {
148  $phraseId = intval($request->get('id'));
149  $status = intval($request->get('status'));
150 
151  if (!$phraseId) {
152  return new JsonResponse(array('error' => 'Invalid phrase ID'), 400);
153  }
154 
155  try {
157  $dbManager = $this->getObject('db.manager');
158 
159  $sql = "UPDATE custom_phrase SET is_active = $1 WHERE cp_pk = $2";
160  $dbManager->prepare($stmt = __METHOD__ . ".toggle", $sql);
161  $dbManager->freeResult($dbManager->execute($stmt, array($status ? 'true' : 'false', $phraseId)));
162 
163  return new JsonResponse(array(
164  'success' => true,
165  'message' => $status ? 'Custom text activated' : 'Custom text deactivated'
166  ));
167  } catch (\Exception $e) {
168  return new JsonResponse(array('error' => 'Failed to toggle status: ' . $e->getMessage()), 500);
169  }
170  }
171 
172  private function getTotalPhrasesCount($searchQuery = '')
173  {
175  $dbManager = $this->getObject('db.manager');
176 
177  $sql = "SELECT COUNT(DISTINCT cp.cp_pk) AS count
178  FROM custom_phrase cp
179  LEFT JOIN users u ON cp.user_fk = u.user_pk
180  LEFT JOIN custom_phrase_license_map cplm ON cp.cp_pk = cplm.cp_fk
181  LEFT JOIN license_ref lr ON cplm.rf_fk = lr.rf_pk";
182 
183  $params = array();
184 
185  if (!empty($searchQuery)) {
186  $sql .= " WHERE (cp.text ILIKE $1 OR cplm.reportinfo ILIKE $1"
187  . " OR cplm.acknowledgement ILIKE $1 OR cplm.comment ILIKE $1"
188  . " OR u.user_name ILIKE $1 OR lr.rf_shortname ILIKE $1)";
189  $params[] = $searchQuery;
190  }
191 
192  $stmtName = empty($searchQuery) ? __METHOD__ . '.unfiltered' : __METHOD__ . '.filtered';
193  $result = $dbManager->getSingleRow($sql, $params, $stmtName);
194  return $result ? intval($result['count']) : 0;
195  }
196 
200  private function getPhrasesServerSide($limit, $offset, $searchQuery = '')
201  {
203  $dbManager = $this->getObject('db.manager');
204 
205  $sql = "SELECT cp.cp_pk, cp.text, cp.created_date, cp.is_active,
206  u.user_name,
207  STRING_AGG(CASE WHEN cplm.removing = false THEN lr.rf_shortname END, ', ' ORDER BY lr.rf_shortname) as licenses_to_add,
208  STRING_AGG(CASE WHEN cplm.removing = true THEN lr.rf_shortname END, ', ' ORDER BY lr.rf_shortname) as licenses_to_remove,
209  STRING_AGG(CASE WHEN cplm.reportinfo IS NOT NULL AND cplm.reportinfo != '' THEN cplm.reportinfo END, '; ') as reportinfo,
210  STRING_AGG(CASE WHEN cplm.acknowledgement IS NOT NULL AND cplm.acknowledgement != '' THEN cplm.acknowledgement END, '; ') as acknowledgement,
211  STRING_AGG(CASE WHEN cplm.comment IS NOT NULL AND cplm.comment != '' THEN cplm.comment END, '; ') as comments
212  FROM custom_phrase cp
213  LEFT JOIN users u ON cp.user_fk = u.user_pk
214  LEFT JOIN custom_phrase_license_map cplm ON cp.cp_pk = cplm.cp_fk
215  LEFT JOIN license_ref lr ON cplm.rf_fk = lr.rf_pk";
216 
217  $params = array();
218 
219  // Filter by cp_pk via subquery: a WHERE on the outer join would drop
220  // non-matching license rows from STRING_AGG too, truncating the list.
221  if (!empty($searchQuery)) {
222  $sql .= " WHERE cp.cp_pk IN (
223  SELECT cp2.cp_pk FROM custom_phrase cp2
224  LEFT JOIN users u2 ON cp2.user_fk = u2.user_pk
225  LEFT JOIN custom_phrase_license_map cplm2 ON cp2.cp_pk = cplm2.cp_fk
226  LEFT JOIN license_ref lr2 ON cplm2.rf_fk = lr2.rf_pk
227  WHERE (cp2.text ILIKE $1 OR cplm2.reportinfo ILIKE $1"
228  . " OR cplm2.acknowledgement ILIKE $1 OR cplm2.comment ILIKE $1"
229  . " OR u2.user_name ILIKE $1 OR lr2.rf_shortname ILIKE $1)
230  )";
231  $params[] = $searchQuery;
232  }
233 
234  $sql .= " GROUP BY cp.cp_pk, cp.text, cp.created_date, cp.is_active, u.user_name
235  ORDER BY cp.created_date DESC";
236 
237  $sql .= " LIMIT $" . (count($params) + 1) . " OFFSET $" . (count($params) + 2);
238  $params[] = $limit;
239  $params[] = $offset;
240 
241  $stmtName = empty($searchQuery) ? __METHOD__ . '.unfiltered' : __METHOD__ . '.filtered';
242  $result = $dbManager->getRows($sql, $params, $stmtName);
243  $aaData = array();
244 
245  foreach ($result as $row) {
246  $text = mb_strlen($row['text'], 'UTF-8') > 100
247  ? mb_substr($row['text'], 0, 100, 'UTF-8') . '...'
248  : $row['text'];
249 
250  $licensesToAdd = $row['licenses_to_add'] ?: null;
251  $licensesToRemove = $row['licenses_to_remove'] ?: null;
252  $licensesDisplay = '';
253  if ($licensesToAdd || $licensesToRemove) {
254  if ($licensesToAdd) {
255  $licensesDisplay .= '<div><img src="images/icons/add_16.png" alt="+" title="To be added"/> ' . htmlspecialchars($licensesToAdd, ENT_QUOTES, 'UTF-8') . '</div>';
256  }
257  if ($licensesToRemove) {
258  $licensesDisplay .= '<div><img src="images/icons/remove_16.png" alt="-" title="To be removed"/> ' . htmlspecialchars($licensesToRemove, ENT_QUOTES, 'UTF-8') . '</div>';
259  }
260  } else {
261  $licensesDisplay = 'N/A';
262  }
263 
264  $reportinfo = trim($row['reportinfo'] ?: '');
265  $acknowledgement = trim($row['acknowledgement'] ?: '');
266  $comments = trim($row['comments'] ?: '');
267 
268  $makeIndicator = function($content, $letter, $label, $typeClass) {
269  $has = $content !== '';
270  $tip = $has
271  ? $label . ': ' . htmlspecialchars(mb_substr($content, 0, 150) . (mb_strlen($content) > 150 ? '…' : ''), ENT_QUOTES)
272  : $label . ': not set';
273  $class = 'custom-text-indicator ' . ($has ? $typeClass : 'custom-text-indicator-empty');
274  return '<span class="' . $class . '" title="' . $tip . '">' . $letter . '</span>';
275  };
276 
277  $detailsCell = '<div class="custom-text-details-cell">'
278  . $makeIndicator($reportinfo, 'T', 'License Text', 'custom-text-indicator-reportinfo')
279  . $makeIndicator($acknowledgement, 'A', 'Acknowledgement', 'custom-text-indicator-acknowledgement')
280  . $makeIndicator($comments, 'C', 'Comment', 'custom-text-indicator-comment')
281  . '</div>';
282 
283  $isActiveFlag = $dbManager->booleanFromDb($row['is_active']);
284  $statusToggle = '<input type="checkbox" class="phrase-status-toggle"'
285  . ' data-phrase-id="' . intval($row['cp_pk']) . '"'
286  . ' data-current-status="' . ($isActiveFlag ? '1' : '0') . '"'
287  . ' title="' . ($isActiveFlag ? 'Active' : 'Inactive') . '"'
288  . ($isActiveFlag ? ' checked' : '') . '/>';
289 
290  $editLink = '<a href="?mod=admin_custom_text_management&edit=' . intval($row['cp_pk']) . '"'
291  . ' title="Edit"><img border="0" src="images/button_edit.png" alt="Edit"/></a>';
292 
293  $deleteBtn = '<button type="button" class="custom-text-delete custom-text-delete-btn btn btn-link btn-sm p-0"'
294  . ' data-phrase-id="' . intval($row['cp_pk']) . '" title="Delete">'
295  . '&times;</button>';
296 
297  $aaData[] = array(
298  $licensesDisplay,
299  '<div class="custom-text-cell">' . nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')) . '</div>',
300  $detailsCell,
301  htmlspecialchars($row['user_name'] ?: 'N/A', ENT_QUOTES, 'UTF-8'),
302  '<div class="custom-text-actions-cell">'
303  . $editLink . $statusToggle . $deleteBtn . '</div>'
304  );
305  }
306 
307  return $aaData;
308  }
309 
316  private function getTotalBulkCount($searchQuery = '', $userFk = 0)
317  {
319  $dbManager = $this->getObject('db.manager');
320 
321  $sql = "SELECT COUNT(DISTINCT lrb.lrb_pk) AS count
322  FROM license_ref_bulk lrb
323  INNER JOIN license_set_bulk lsb ON lrb.lrb_pk = lsb.lrb_fk
324  INNER JOIN license_ref lr ON lsb.rf_fk = lr.rf_pk
325  LEFT JOIN users u ON lrb.user_fk = u.user_pk
326  LEFT JOIN upload up ON lrb.upload_fk = up.upload_pk
327  WHERE lrb.rf_text IS NOT NULL AND lrb.rf_text != ''";
328 
329  $params = array();
330  $paramIdx = 1;
331 
332  if (!empty($searchQuery)) {
333  $sql .= " AND (lrb.rf_text ILIKE \$$paramIdx OR lr.rf_shortname ILIKE \$$paramIdx OR u.user_name ILIKE \$$paramIdx OR up.upload_filename ILIKE \$$paramIdx)";
334  $params[] = $searchQuery;
335  $paramIdx++;
336  }
337 
338  if ($userFk > 0) {
339  $sql .= " AND lrb.user_fk = \$$paramIdx";
340  $params[] = $userFk;
341  }
342 
343  $result = $dbManager->getSingleRow($sql, $params,
344  $this->bulkStatementName(__METHOD__, $searchQuery, $userFk));
345  return $result ? intval($result['count']) : 0;
346  }
347 
351  private function bulkStatementName($method, $searchQuery, $userFk)
352  {
353  return $method . (empty($searchQuery) ? '' : '.search') . ($userFk > 0 ? '.user' : '');
354  }
355 
362  private function getBulkDataAjax(Request $request)
363  {
364  $page = max(1, intval($request->query->get('page', 1)));
365  $limit = max(1, intval($request->query->get('limit', 10)));
366  $searchTerm = trim($request->query->get('search', ''));
367  $userFk = max(0, intval($request->query->get('user_fk', 0)));
368 
369  $searchQuery = '';
370  if (!empty($searchTerm)) {
371  $searchQuery = '%' . $searchTerm . '%';
372  }
373 
374  $totalRecords = $this->getTotalBulkCount($searchQuery, $userFk);
375  $totalPages = max(1, intval(ceil($totalRecords / $limit)));
376 
377  // Clamp page to valid range
378  if ($page > $totalPages) {
379  $page = $totalPages;
380  }
381  $offset = ($page - 1) * $limit;
382 
384  $dbManager = $this->getObject('db.manager');
385 
386  // Paginate distinct lrb_pk first, then join full row data so every
387  // license row for a paginated bulk entry is returned.
388  $whereClause = "WHERE lrb.rf_text IS NOT NULL AND lrb.rf_text != ''";
389  $params = array();
390  $paramIdx = 1;
391 
392  if (!empty($searchQuery)) {
393  $whereClause .= " AND (lrb.rf_text ILIKE \$$paramIdx OR lr.rf_shortname ILIKE \$$paramIdx OR u.user_name ILIKE \$$paramIdx OR up.upload_filename ILIKE \$$paramIdx)";
394  $params[] = $searchQuery;
395  $paramIdx++;
396  }
397 
398  if ($userFk > 0) {
399  $whereClause .= " AND lrb.user_fk = \$$paramIdx";
400  $params[] = $userFk;
401  $paramIdx++;
402  }
403 
404  // Subquery: get the paginated lrb_pk values
405  $subSql = "SELECT DISTINCT lrb.lrb_pk
406  FROM license_ref_bulk lrb
407  INNER JOIN license_set_bulk lsb ON lrb.lrb_pk = lsb.lrb_fk
408  INNER JOIN license_ref lr ON lsb.rf_fk = lr.rf_pk
409  LEFT JOIN users u ON lrb.user_fk = u.user_pk
410  LEFT JOIN upload up ON lrb.upload_fk = up.upload_pk
411  $whereClause
412  ORDER BY lrb.lrb_pk DESC
413  LIMIT \$$paramIdx OFFSET \$" . ($paramIdx + 1);
414  $params[] = $limit;
415  $params[] = $offset;
416  $paramIdx += 2;
417 
418  // Main query: fetch full data only for the paginated lrb_pk set
419  $sql = "SELECT
420  lrb.lrb_pk,
421  lrb.rf_text as bulk_reference_text,
422  lrb.ignore_irrelevant,
423  lrb.bulk_delimiters,
424  lrb.scan_findings,
425 
426  lr.rf_pk as license_id,
427  lr.rf_shortname as license_shortname,
428  lr.rf_fullname as license_fullname,
429  lr.rf_spdx_id as license_spdx_id,
430 
431  lsb.removing as is_removing_license,
432  lsb.comment as license_comment,
433  lsb.reportinfo as license_reportinfo,
434  lsb.acknowledgement as license_acknowledgement,
435 
436  u.user_name as created_by_user,
437  up.upload_filename as upload_file,
438 
439  lrb.user_fk,
440  lrb.group_fk,
441  lrb.upload_fk,
442  lrb.uploadtree_fk
443 
444  FROM license_ref_bulk lrb
445  INNER JOIN license_set_bulk lsb ON lrb.lrb_pk = lsb.lrb_fk
446  INNER JOIN license_ref lr ON lsb.rf_fk = lr.rf_pk
447  LEFT JOIN users u ON lrb.user_fk = u.user_pk
448  LEFT JOIN upload up ON lrb.upload_fk = up.upload_pk
449  WHERE lrb.lrb_pk IN ($subSql)
450  ORDER BY lrb.lrb_pk DESC, lr.rf_shortname";
451 
452  $result = $dbManager->getRows($sql, $params,
453  $this->bulkStatementName(__METHOD__, $searchQuery, $userFk));
454  $bulkData = array();
455 
456  // Group results by lrb_pk to handle multiple licenses per bulk entry
457  $groupedData = array();
458  foreach ($result as $row) {
459  $lrbPk = $row['lrb_pk'];
460 
461  if (!isset($groupedData[$lrbPk])) {
462  $text = strlen($row['bulk_reference_text']) > 200 ?
463  substr($row['bulk_reference_text'], 0, 200) . '...' :
464  $row['bulk_reference_text'];
465 
466  $ignoreIrrelevant = $dbManager->booleanFromDb($row['ignore_irrelevant']);
467  $scanFindings = $dbManager->booleanFromDb($row['scan_findings']);
468 
469  $groupedData[$lrbPk] = array(
470  'lrb_pk' => $lrbPk,
471  'user_fk' => $row['user_fk'],
472  'group_fk' => $row['group_fk'],
473  'bulk_reference_text' => $row['bulk_reference_text'],
474  'text_preview' => $text,
475  'upload_fk' => $row['upload_fk'],
476  'uploadtree_fk' => $row['uploadtree_fk'],
477  'ignore_irrelevant' => $ignoreIrrelevant,
478  'bulk_delimiters' => $row['bulk_delimiters'],
479  'scan_findings' => $scanFindings,
480  'created_by_user' => $row['created_by_user'] ?: 'Unknown',
481  'upload_file' => $row['upload_file'] ?: 'Unknown',
482  'licenses' => array(),
483  'all_acknowledgements' => array(),
484  'all_comments' => array()
485  );
486  }
487 
488  // Add license information
489  $isRemoving = $dbManager->booleanFromDb($row['is_removing_license']);
490  $licenseInfo = array(
491  'license_id' => $row['license_id'],
492  'license_shortname' => $row['license_shortname'],
493  'license_fullname' => $row['license_fullname'],
494  'license_spdx_id' => $row['license_spdx_id'],
495  'is_removing_license' => $isRemoving,
496  'license_comment' => $row['license_comment'],
497  'license_reportinfo' => $row['license_reportinfo'],
498  'license_acknowledgement' => $row['license_acknowledgement']
499  );
500 
501  $groupedData[$lrbPk]['licenses'][] = $licenseInfo;
502 
503  // Collect unique acknowledgements and comments
504  if (!empty($row['license_acknowledgement'])) {
505  $groupedData[$lrbPk]['all_acknowledgements'][] = $row['license_acknowledgement'];
506  }
507  if (!empty($row['license_comment'])) {
508  $groupedData[$lrbPk]['all_comments'][] = $row['license_comment'];
509  }
510  }
511 
512  // Convert grouped data to final format
513  foreach ($groupedData as $entry) {
514  $licenseNames = array();
515  $addedLicenses = array();
516  $removedLicenses = array();
517 
518  foreach ($entry['licenses'] as $license) {
519  $licenseNames[] = $license['license_shortname'];
520  if ($license['is_removing_license']) {
521  $removedLicenses[] = $license['license_shortname'];
522  } else {
523  $addedLicenses[] = $license['license_shortname'];
524  }
525  }
526 
527  // Create summary strings
528  $entry['license_summary'] = implode(', ', $licenseNames);
529  $entry['added_licenses'] = implode(', ', $addedLicenses);
530  $entry['removed_licenses'] = implode(', ', $removedLicenses);
531  $entry['acknowledgement_summary'] = implode('; ', array_unique($entry['all_acknowledgements']));
532  $entry['comment_summary'] = implode('; ', array_unique($entry['all_comments']));
533 
534  $bulkData[] = $entry;
535  }
536 
537  return new JsonResponse(array(
538  'data' => $bulkData,
539  'totalRecords' => $totalRecords,
540  'totalPages' => $totalPages,
541  'currentPage' => $page,
542  'pageSize' => $limit
543  ));
544  }
545 }
546 
547 register_plugin(new AdminCustomTextList());
Contains the constants and helpers for authentication of user.
Definition: Auth.php:24
static getUserId()
Get the current user's id.
Definition: Auth.php:68
static getGroupId()
Get the current user's group id.
Definition: Auth.php:80
static isAdmin()
Check if user is admin.
Definition: Auth.php:92
render($templateName, $vars=null, $headers=null)
bulkStatementName($method, $searchQuery, $userFk)
Traceback_uri()
Get the URI without query to this location.
Definition: common-parm.php:97
char * trim(char *ptext)
Trimming whitespace.
Definition: fossconfig.c:690
FUNCTION int max(int permGroup, int permPublic)
Get the maximum group privilege.
Definition: libfossagent.c:295