001/* 002 * Copyright 2023 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.workflow.dao; 017 018import java.io.File; 019import java.io.FileNotFoundException; 020import java.io.IOException; 021import java.io.PrintWriter; 022import java.nio.charset.StandardCharsets; 023import java.nio.file.StandardCopyOption; 024import java.util.ArrayList; 025import java.util.HashMap; 026import java.util.HashSet; 027import java.util.LinkedHashMap; 028import java.util.List; 029import java.util.Map; 030import java.util.Set; 031 032import org.apache.avalon.framework.component.Component; 033import org.apache.avalon.framework.context.ContextException; 034import org.apache.avalon.framework.context.Contextualizable; 035import org.apache.avalon.framework.service.ServiceException; 036import org.apache.avalon.framework.service.ServiceManager; 037import org.apache.avalon.framework.service.Serviceable; 038import org.apache.cocoon.Constants; 039import org.apache.cocoon.environment.Context; 040import org.apache.commons.io.FileUtils; 041import org.apache.commons.lang3.ArrayUtils; 042import org.apache.commons.lang3.StringUtils; 043import org.apache.excalibur.source.SourceResolver; 044 045import org.ametys.core.observation.Event; 046import org.ametys.core.observation.ObservationManager; 047import org.ametys.core.ui.Callable; 048import org.ametys.core.user.CurrentUserProvider; 049import org.ametys.core.util.I18nUtils; 050import org.ametys.plugins.workflow.ObservationConstants; 051import org.ametys.plugins.workflow.component.WorkflowLanguageManager; 052import org.ametys.plugins.workflow.definition.WorkflowDefinitionExtensionPoint; 053import org.ametys.plugins.workflow.support.I18nHelper; 054import org.ametys.plugins.workflow.support.WorflowRightHelper; 055import org.ametys.plugins.workflow.support.WorkflowHelper; 056import org.ametys.plugins.workflow.support.WorkflowSessionHelper; 057import org.ametys.runtime.i18n.I18nizableText; 058import org.ametys.runtime.plugin.component.AbstractLogEnabled; 059 060import com.opensymphony.workflow.loader.AbstractDescriptor; 061import com.opensymphony.workflow.loader.ActionDescriptor; 062import com.opensymphony.workflow.loader.ConditionalResultDescriptor; 063import com.opensymphony.workflow.loader.ConditionsDescriptor; 064import com.opensymphony.workflow.loader.DescriptorFactory; 065import com.opensymphony.workflow.loader.RestrictionDescriptor; 066import com.opensymphony.workflow.loader.WorkflowDescriptor; 067 068/** 069 * DAO for managing workflows 070 */ 071public class WorkflowsDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable 072{ 073 /** The Avalon role */ 074 public static final String ROLE = WorkflowsDAO.class.getName(); 075 /** Meta to set in new workflows */ 076 public static final String META_NEW_WORKFLOW = "new-workflow"; 077 078 /** The workflow helper */ 079 protected WorkflowHelper _workflowHelper; 080 081 /** The workflow session helper */ 082 protected WorkflowSessionHelper _workflowSessionHelper; 083 084 /** The workflow right helper */ 085 protected WorflowRightHelper _workflowRightHelper; 086 087 /** The workflow language manager */ 088 protected WorkflowLanguageManager _workflowLanguageManager; 089 090 /** The helper for i18n translations and catalogs */ 091 protected I18nHelper _i18nHelper; 092 093 /** The Cocoon context */ 094 protected Context _cocoonContext; 095 096 /** I18n Utils */ 097 protected I18nUtils _i18nUtils; 098 099 /** The context */ 100 protected org.apache.avalon.framework.context.Context _context; 101 102 /** The regex pattern for workflow names */ 103 protected final String _regexPattern = "^[a-zA-Z\\-]+$"; 104 105 /** The Workflow Definition Extension Point */ 106 protected WorkflowDefinitionExtensionPoint _workflowDefinitionEP; 107 108 /** The workflow transition DAO */ 109 protected WorkflowTransitionDAO _workflowTransitionDAO; 110 111 /** The observation manager */ 112 protected ObservationManager _observationManager; 113 114 /** The current user provider */ 115 protected CurrentUserProvider _currentUserProvider; 116 117 /** The source resolver */ 118 protected SourceResolver _sourceResolver; 119 120 @Override 121 public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException 122 { 123 _context = context; 124 _cocoonContext = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT); 125 } 126 127 public void service(ServiceManager manager) throws ServiceException 128 { 129 _sourceResolver = (SourceResolver) manager.lookup(org.apache.excalibur.source.SourceResolver.ROLE); 130 _workflowDefinitionEP = (WorkflowDefinitionExtensionPoint) manager.lookup(WorkflowDefinitionExtensionPoint.ROLE); 131 _workflowHelper = (WorkflowHelper) manager.lookup(WorkflowHelper.ROLE); 132 _workflowRightHelper = (WorflowRightHelper) manager.lookup(WorflowRightHelper.ROLE); 133 _workflowSessionHelper = (WorkflowSessionHelper) manager.lookup(WorkflowSessionHelper.ROLE); 134 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 135 _workflowLanguageManager = (WorkflowLanguageManager) manager.lookup(WorkflowLanguageManager.ROLE); 136 _i18nHelper = (I18nHelper) manager.lookup(I18nHelper.ROLE); 137 _workflowTransitionDAO = (WorkflowTransitionDAO) manager.lookup(WorkflowTransitionDAO.ROLE); 138 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 139 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 140 } 141 142 /** 143 * Get the workflow properties 144 * @param workflowName the name of the workflow to get 145 * @return the workflow properties 146 */ 147 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 148 public Map<String, Object> getWorkflowRootProperties(String workflowName) 149 { 150 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 151 Map<String, Object> infos = new HashMap<>(); 152 if (workflowDescriptor != null && _workflowRightHelper.canRead(workflowDescriptor)) 153 { 154 infos.put("id", workflowName); 155 infos.put("label", _workflowSessionHelper.getWorkflowLabel(workflowName)); 156 infos.put("hasChildren", workflowDescriptor.getSteps().size() > 0); 157 infos.put("hasChanges", _workflowSessionHelper.hasChanges(workflowName)); 158 infos.put("isNew", workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW)); 159 infos.put("canWrite", _workflowRightHelper.canWrite(workflowDescriptor)); 160 } 161 else 162 { 163 String errorMsg = workflowDescriptor != null ? "cant-read" : "workflow-unknown"; 164 infos.put("error", errorMsg); 165 } 166 return infos; 167 } 168 169 /** 170 * Get the list of all workflows 171 * @return a map with workflow's list as value 172 */ 173 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 174 public Map<String, Object> getWorkflowsList() 175 { 176 List<Map<String, Object>> workflows2json = new ArrayList<>(); 177 178 Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames(); 179 for (String workflowName: workflowNames) 180 { 181 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 182 if (_workflowRightHelper.canRead(workflowDescriptor)) 183 { 184 Map<String, Object> workflowData = new LinkedHashMap<>(); 185 workflowData.put("title", _workflowSessionHelper.getWorkflowLabel(workflowName)); 186 workflowData.put("id", workflowName); 187 workflowData.put("hasChanges", _workflowSessionHelper.hasChanges(workflowName)); 188 workflows2json.add(workflowData); 189 } 190 } 191 192 return Map.of("workflows", workflows2json); 193 } 194 195 /** 196 * Overwrite the current workflow in a XML file 197 * @param workflowName id of current workflow 198 * @return an empty map if all went well, an error message if not 199 */ 200 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 201 public Map<String, Object> saveChanges(String workflowName) 202 { 203 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 204 205 // Check user right 206 _workflowRightHelper.checkEditRight(workflowDescriptor); 207 208 Map<String, Object> results = new HashMap<>(); 209 210 try 211 { 212 // Write new workflow 213 if (!_setErrors(workflowDescriptor, results)) 214 { 215 workflowDescriptor.getMetaAttributes().remove(META_NEW_WORKFLOW); 216 _writeWorklowFile(workflowName, workflowDescriptor); 217 218 // Write new i18n 219 _writeI18nTranslations(workflowName, _workflowSessionHelper.getTranslations(workflowName)); 220 221 _workflowSessionHelper.cloneImages(workflowName); 222 _workflowSessionHelper.deleteSession(workflowName); 223 _workflowDefinitionEP.addOrUpdateExtension(workflowName); 224 225 _i18nHelper.clearCaches(); 226 227 Map<String, Object> params = new HashMap<>(); 228 params.put(ObservationConstants.ARGS_WORKFLOW_NAME, workflowName); 229 _observationManager.notify(new Event(ObservationConstants.EVENT_WORKFLOW_SAVED, _currentUserProvider.getUser(), params)); 230 } 231 results.put("workflowId", workflowName); 232 } 233 catch (FileNotFoundException e) 234 { 235 results.put("message", "file-not-found"); 236 getLogger().error("An error occured while overwriting workflow file: {}", workflowName, e); 237 } 238 catch (Exception e) 239 { 240 results.put("message", "sax-error"); 241 getLogger().error("An error occured while saxing i18n catalogs file", e); 242 } 243 return results; 244 } 245 246 /** 247 * Restore last version of current workflow if exist 248 * @param workflowName name of current workflow 249 * @return map of result 250 */ 251 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 252 public Map<String, Object> reinit(String workflowName) 253 { 254 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 255 256 // // Check user right 257 _workflowRightHelper.checkEditRight(workflowDescriptor); 258 259 if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW)) 260 { 261 return Map.of("message", "new_workflow"); 262 } 263 _workflowSessionHelper.deleteSession(workflowName); 264 return Map.of("workflowId", workflowName); 265 } 266 267 /** 268 * Check for invalid components in workflow, return true if there is any 269 * @param workflowDescriptor the workflow to check 270 * @param results a map to fill with error message and invalid component's labels 271 * @return true if there are errors 272 */ 273 protected boolean _setErrors(WorkflowDescriptor workflowDescriptor, Map<String, Object> results) 274 { 275 if (workflowDescriptor.getInitialActions().isEmpty()) 276 { 277 results.put("message", "empty-initials-actions"); 278 return true; 279 } 280 Set<Integer> transitionIds = _workflowHelper.getAllActions(workflowDescriptor); 281 return _hasEmptyOperator(workflowDescriptor, transitionIds, results) || _hasEmptyConditionalResult(workflowDescriptor, results, transitionIds); 282 } 283 284 /** 285 * Check for result without condition or operators without conditions 286 * @param workflowDescriptor the workflow to check 287 * @param results a map to fill with error message and invalid transition's labels 288 * @param transitionIds a list of all the workflow's transitions ids 289 * @return true if there are invalid conditional results 290 */ 291 protected boolean _hasEmptyConditionalResult(WorkflowDescriptor workflowDescriptor, Map<String, Object> results, Set<Integer> transitionIds) 292 { 293 Set<String> invalidTransitions = new HashSet<>(); 294 for (Integer id : transitionIds) 295 { 296 ActionDescriptor action = workflowDescriptor.getAction(id); 297 String actionLabel = _workflowTransitionDAO.getActionLabel(workflowDescriptor.getName(), action) + "(" + action.getId() + ")"; 298 299 List<ConditionalResultDescriptor> conditionalResults = action.getConditionalResults(); 300 for (ConditionalResultDescriptor resultDescriptor : conditionalResults) 301 { 302 List<ConditionsDescriptor> conditions = resultDescriptor.getConditions(); 303 if (conditions.isEmpty()) 304 { 305 results.put("message", "empty-conditionnal-result"); 306 invalidTransitions.add(actionLabel); 307 } 308 else if (_hasOperatorWithoutChild(conditions.get(0))) 309 { 310 results.put("message", "empty-result-operator"); 311 invalidTransitions.add(actionLabel); 312 } 313 } 314 } 315 316 results.put("invalidTransitions", invalidTransitions.isEmpty() ? null : invalidTransitions); 317 return results.get("message") != null; 318 } 319 320 /** 321 * Check for operators without conditions 322 * @param workflowDescriptor the workflow to check 323 * @param transitionIds a list of all the workflow's transitions ids 324 * @param results a map to fill with error message and invalid transition's labels 325 * @return true if there are operator without conditions 326 */ 327 protected boolean _hasEmptyOperator(WorkflowDescriptor workflowDescriptor, Set<Integer> transitionIds, Map<String, Object> results) 328 { 329 Set<String> invalidTransitions = new HashSet<>(); 330 for (Integer id : transitionIds) 331 { 332 ActionDescriptor action = workflowDescriptor.getAction(id); 333 if (action != null) // case in kernel workflow where an action is declared but never used 334 { 335 String actionLabel = _i18nHelper.translateKey(workflowDescriptor.getName(), new I18nizableText("application", action.getName()), WorkflowTransitionDAO.DEFAULT_ACTION_NAME) + " (" + action.getId() + ")"; 336 RestrictionDescriptor restriction = action.getRestriction(); 337 if (restriction != null) 338 { 339 ConditionsDescriptor rootOperator = restriction.getConditionsDescriptor(); 340 if (_hasOperatorWithoutChild(rootOperator)) 341 { 342 invalidTransitions.add(actionLabel); 343 } 344 } 345 } 346 } 347 if (!invalidTransitions.isEmpty()) 348 { 349 results.put("message", "empty-condition-operator"); 350 results.put("invalidTransitions", invalidTransitions); 351 return true; 352 } 353 return false; 354 } 355 356 private boolean _hasOperatorWithoutChild(ConditionsDescriptor rootOperator) 357 { 358 List<AbstractDescriptor> conditions = rootOperator.getConditions(); 359 if (conditions.isEmpty()) 360 { 361 return true; 362 } 363 else 364 { 365 int i = 0; 366 boolean hasChildFreeOperator = false; 367 while (i < conditions.size() && !hasChildFreeOperator) 368 { 369 if (conditions.get(i) instanceof ConditionsDescriptor operator) 370 { 371 hasChildFreeOperator = _hasOperatorWithoutChild(operator); 372 } 373 i++; 374 } 375 return hasChildFreeOperator; 376 } 377 } 378 379 private void _writeI18nTranslations(String workflowName, Map<I18nizableText, Map<String, String>> translations) throws Exception 380 { 381 if (!translations.isEmpty()) 382 { 383 // Write new i18n messages in application 384 String workflowCatalog = _workflowHelper.getWorkflowCatalog(workflowName); 385 Map<String, Map<I18nizableText, String>> newI18nCatalogs = _i18nHelper.createNewI18nCatalogs(translations); 386 _i18nHelper.saveCatalogs(newI18nCatalogs, workflowCatalog); 387 } 388 } 389 390 private void _writeWorklowFile(String workflowName, WorkflowDescriptor workflowDescriptor) throws IOException 391 { 392 File workflowFile = new File(_workflowHelper.getParamWorkflowDir(), workflowName + ".xml"); 393 394 // Save the workflow file if it already exists 395 if (workflowFile.exists()) 396 { 397 FileUtils.copyFile(workflowFile, new File(workflowFile + ".bak"), StandardCopyOption.REPLACE_EXISTING); 398 } 399 // Otherwise, create the file and its parents if necessary 400 else 401 { 402 FileUtils.createParentDirectories(workflowFile); 403 workflowFile.createNewFile(); 404 } 405 406 try (PrintWriter out = new PrintWriter(workflowFile, StandardCharsets.UTF_8)) 407 { 408 out.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); 409 out.println("<!DOCTYPE workflow PUBLIC \"-//OpenSymphony Group//DTD OSWorkflow 2.8//EN\" \"http://www.opensymphony.com/osworkflow/workflow_2_8.dtd\">"); 410 workflowDescriptor.writeXML(out, 0); 411 } 412 } 413 414 /** 415 * Get multilingual labels for current workflow 416 * @param workflowName name of current workflow 417 * @return a map of labels, key is language and value is translation 418 */ 419 protected Map<String, String> _getWorkflowLabels(String workflowName) 420 { 421 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 422 _workflowRightHelper.checkEditRight(workflowDescriptor); 423 Map<String, String> workflowLabelTranslations = _workflowSessionHelper.getWorkflowLabelTranslations(workflowName); 424 if (workflowLabelTranslations.isEmpty() && ArrayUtils.contains(_workflowHelper.getWorkflowNames(), workflowName)) 425 { 426 I18nizableText workflowI18nLabelKey = _workflowHelper.getWorkflowLabel(workflowName); 427 for (String language : _workflowLanguageManager.getLanguages()) 428 { 429 workflowLabelTranslations.put(language, StringUtils.defaultString(_i18nUtils.translate(workflowI18nLabelKey, language))); 430 } 431 } 432 return workflowLabelTranslations; 433 } 434 435 /** 436 * Get workflow infos 437 * @param workflowName the name of the workflow 438 * @return a map of the list of workflow names and the workflow's labels 439 */ 440 @Callable(rights = {"Workflow_Right_Edit", "Workflow_Right_Edit_User"}) 441 public Map<String, Object> getWorkflowInfos(String workflowName) 442 { 443 Map<String, Object> workflowInfos = new HashMap<>(); 444 workflowInfos.put("workflowNames", _workflowSessionHelper.getWorkflowNames()); 445 if (StringUtils.isNotBlank(workflowName)) 446 { 447 workflowInfos.put("labels", _getWorkflowLabels(workflowName)); 448 } 449 return workflowInfos; 450 } 451 452 /** 453 * Create a new workflow 454 * @param labels the multilingual labels 455 * @param id the unique name 456 * @return map of error message or workflow name if creation went well 457 */ 458 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 459 public Map<String, Object> createWorkflow(Map<String, String> labels, String id) 460 { 461 // Check user right 462 _workflowRightHelper.checkEditRight(); 463 464 Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames(); 465 if (workflowNames.contains(id)) 466 { 467 return Map.of("message", "duplicate-id"); 468 } 469 470 //create workflow descriptor 471 DescriptorFactory factory = new DescriptorFactory(); 472 WorkflowDescriptor workflowDescriptor = factory.createWorkflowDescriptor(); 473 workflowDescriptor.setName(id); 474 workflowDescriptor.getMetaAttributes().put("user", true); 475 476 //add meta to new workflow to prevent reinit 477 workflowDescriptor.getMetaAttributes().put(META_NEW_WORKFLOW, true); 478 479 //save workflow in session 480 _workflowSessionHelper.initWorkflowDescriptor(workflowDescriptor); 481 _workflowSessionHelper.updateWorkflowNames(workflowDescriptor); 482 483 //add workflow label translations 484 _workflowSessionHelper.updateTranslations(id, _i18nHelper.getWorkflowLabelKey(id), labels); 485 486 return Map.of("workflowId", id); 487 } 488 489 /** 490 * Rename the workflow 491 * @param workflowName unique name of current workflow 492 * @param labels the new multilingual labels 493 * @return the workflow name 494 */ 495 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 496 public Map<String, Object> renameWorkflow(String workflowName, Map<String, String> labels) 497 { 498 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 499 500 // Check user right 501 _workflowRightHelper.checkEditRight(workflowDescriptor); 502 503 // Create i18n entry 504 I18nizableText labelKey = _i18nHelper.getWorkflowLabelKey(workflowName); 505 _workflowSessionHelper.updateTranslations(workflowName, labelKey, labels); 506 return Map.of("workflowId", workflowName); 507 } 508 509 /** 510 * Duplicate a workflow 511 * @param newWorkflowName the new workflow name 512 * @param labels the new labels for the workflow 513 * @param duplicatedWorkflowName the duplicated workflow name 514 * @return map of results 515 */ 516 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 517 public Map<String, Object> duplicateWorkflow(String newWorkflowName, Map<String, String> labels, String duplicatedWorkflowName) 518 { 519 // Check user right 520 _workflowRightHelper.checkEditRight(); 521 522 Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames(); 523 if (workflowNames.contains(newWorkflowName)) 524 { 525 return Map.of("message", "duplicate-id"); 526 } 527 528 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(duplicatedWorkflowName, false); 529 if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW)) 530 { 531 return Map.of("message", "no-save"); 532 } 533 534 _workflowSessionHelper.duplicateWorkflow(newWorkflowName, labels, duplicatedWorkflowName); 535 536 return Map.of("workflowId", newWorkflowName); 537 } 538 539}