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.SKIP_BUILTIN_CHECK) 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.SKIP_BUILTIN_CHECK) 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.SKIP_BUILTIN_CHECK) 201 public Map<String, Object> saveChanges(String workflowName) 202 { 203 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 204 _workflowRightHelper.checkEditRight(workflowDescriptor); 205 Map<String, Object> results = new HashMap<>(); 206 207 try 208 { 209 // Write new workflow 210 if (!_setErrors(workflowDescriptor, results)) 211 { 212 workflowDescriptor.getMetaAttributes().remove(META_NEW_WORKFLOW); 213 _writeWorklowFile(workflowName, workflowDescriptor); 214 215 // Write new i18n 216 _writeI18nTranslations(workflowName, _workflowSessionHelper.getTranslations(workflowName)); 217 218 _workflowSessionHelper.cloneImages(workflowName); 219 _workflowSessionHelper.deleteSession(workflowName); 220 _workflowDefinitionEP.addOrUpdateExtension(workflowName); 221 222 _i18nHelper.clearCaches(); 223 224 Map<String, Object> params = new HashMap<>(); 225 params.put(ObservationConstants.ARGS_WORKFLOW_NAME, workflowName); 226 _observationManager.notify(new Event(ObservationConstants.EVENT_WORKFLOW_SAVED, _currentUserProvider.getUser(), params)); 227 } 228 results.put("workflowId", workflowName); 229 } 230 catch (FileNotFoundException e) 231 { 232 results.put("message", "file-not-found"); 233 getLogger().error("An error occured while overwriting workflow file: {}", workflowName, e); 234 } 235 catch (Exception e) 236 { 237 results.put("message", "sax-error"); 238 getLogger().error("An error occured while saxing i18n catalogs file", e); 239 } 240 return results; 241 } 242 243 /** 244 * Restore last version of current workflow if exist 245 * @param workflowName name of current workflow 246 * @return map of result 247 */ 248 @Callable(rights = Callable.SKIP_BUILTIN_CHECK) 249 public Map<String, Object> reinit(String workflowName) 250 { 251 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 252 _workflowRightHelper.checkEditRight(workflowDescriptor); 253 if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW)) 254 { 255 return Map.of("message", "new_workflow"); 256 } 257 _workflowSessionHelper.deleteSession(workflowName); 258 return Map.of("workflowId", workflowName); 259 } 260 261 /** 262 * Check for invalid components in workflow, return true if there is any 263 * @param workflowDescriptor the workflow to check 264 * @param results a map to fill with error message and invalid component's labels 265 * @return true if there are errors 266 */ 267 protected boolean _setErrors(WorkflowDescriptor workflowDescriptor, Map<String, Object> results) 268 { 269 if (workflowDescriptor.getInitialActions().isEmpty()) 270 { 271 results.put("message", "empty-initials-actions"); 272 return true; 273 } 274 Set<Integer> transitionIds = _workflowHelper.getAllActions(workflowDescriptor); 275 return _hasEmptyOperator(workflowDescriptor, transitionIds, results) || _hasEmptyConditionalResult(workflowDescriptor, results, transitionIds); 276 } 277 278 /** 279 * Check for result without condition or operators without conditions 280 * @param workflowDescriptor the workflow to check 281 * @param results a map to fill with error message and invalid transition's labels 282 * @param transitionIds a list of all the workflow's transitions ids 283 * @return true if there are invalid conditional results 284 */ 285 protected boolean _hasEmptyConditionalResult(WorkflowDescriptor workflowDescriptor, Map<String, Object> results, Set<Integer> transitionIds) 286 { 287 Set<String> invalidTransitions = new HashSet<>(); 288 for (Integer id : transitionIds) 289 { 290 ActionDescriptor action = workflowDescriptor.getAction(id); 291 String actionLabel = _workflowTransitionDAO.getActionLabel(workflowDescriptor.getName(), action) + "(" + action.getId() + ")"; 292 293 List<ConditionalResultDescriptor> conditionalResults = action.getConditionalResults(); 294 for (ConditionalResultDescriptor resultDescriptor : conditionalResults) 295 { 296 List<ConditionsDescriptor> conditions = resultDescriptor.getConditions(); 297 if (conditions.isEmpty()) 298 { 299 results.put("message", "empty-conditionnal-result"); 300 invalidTransitions.add(actionLabel); 301 } 302 else if (_hasOperatorWithoutChild(conditions.get(0))) 303 { 304 results.put("message", "empty-result-operator"); 305 invalidTransitions.add(actionLabel); 306 } 307 } 308 } 309 310 results.put("invalidTransitions", invalidTransitions.isEmpty() ? null : invalidTransitions); 311 return results.get("message") != null; 312 } 313 314 /** 315 * Check for operators without conditions 316 * @param workflowDescriptor the workflow to check 317 * @param transitionIds a list of all the workflow's transitions ids 318 * @param results a map to fill with error message and invalid transition's labels 319 * @return true if there are operator without conditions 320 */ 321 protected boolean _hasEmptyOperator(WorkflowDescriptor workflowDescriptor, Set<Integer> transitionIds, Map<String, Object> results) 322 { 323 Set<String> invalidTransitions = new HashSet<>(); 324 for (Integer id : transitionIds) 325 { 326 ActionDescriptor action = workflowDescriptor.getAction(id); 327 if (action != null) // case in kernel workflow where an action is declared but never used 328 { 329 String actionLabel = _i18nHelper.translateKey(workflowDescriptor.getName(), new I18nizableText("application", action.getName()), WorkflowTransitionDAO.DEFAULT_ACTION_NAME) + " (" + action.getId() + ")"; 330 RestrictionDescriptor restriction = action.getRestriction(); 331 if (restriction != null) 332 { 333 ConditionsDescriptor rootOperator = restriction.getConditionsDescriptor(); 334 if (_hasOperatorWithoutChild(rootOperator)) 335 { 336 invalidTransitions.add(actionLabel); 337 } 338 } 339 } 340 } 341 if (!invalidTransitions.isEmpty()) 342 { 343 results.put("message", "empty-condition-operator"); 344 results.put("invalidTransitions", invalidTransitions); 345 return true; 346 } 347 return false; 348 } 349 350 private boolean _hasOperatorWithoutChild(ConditionsDescriptor rootOperator) 351 { 352 List<AbstractDescriptor> conditions = rootOperator.getConditions(); 353 if (conditions.isEmpty()) 354 { 355 return true; 356 } 357 else 358 { 359 int i = 0; 360 boolean hasChildFreeOperator = false; 361 while (i < conditions.size() && !hasChildFreeOperator) 362 { 363 if (conditions.get(i) instanceof ConditionsDescriptor operator) 364 { 365 hasChildFreeOperator = _hasOperatorWithoutChild(operator); 366 } 367 i++; 368 } 369 return hasChildFreeOperator; 370 } 371 } 372 373 private void _writeI18nTranslations(String workflowName, Map<I18nizableText, Map<String, String>> translations) throws Exception 374 { 375 if (!translations.isEmpty()) 376 { 377 // Write new i18n messages in application 378 String workflowCatalog = _workflowHelper.getWorkflowCatalog(workflowName); 379 Map<String, Map<I18nizableText, String>> newI18nCatalogs = _i18nHelper.createNewI18nCatalogs(translations); 380 _i18nHelper.saveCatalogs(newI18nCatalogs, workflowCatalog); 381 } 382 } 383 384 private void _writeWorklowFile(String workflowName, WorkflowDescriptor workflowDescriptor) throws IOException 385 { 386 File workflowFile = new File(_workflowHelper.getParamWorkflowDir(), workflowName + ".xml"); 387 388 // Save the workflow file if it already exists 389 if (workflowFile.exists()) 390 { 391 FileUtils.copyFile(workflowFile, new File(workflowFile + ".bak"), StandardCopyOption.REPLACE_EXISTING); 392 } 393 // Otherwise, create the file and its parents if necessary 394 else 395 { 396 FileUtils.createParentDirectories(workflowFile); 397 workflowFile.createNewFile(); 398 } 399 400 try (PrintWriter out = new PrintWriter(workflowFile, StandardCharsets.UTF_8)) 401 { 402 out.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); 403 out.println("<!DOCTYPE workflow PUBLIC \"-//OpenSymphony Group//DTD OSWorkflow 2.8//EN\" \"http://www.opensymphony.com/osworkflow/workflow_2_8.dtd\">"); 404 workflowDescriptor.writeXML(out, 0); 405 } 406 } 407 408 /** 409 * Get multilingual labels for current workflow 410 * @param workflowName name of current workflow 411 * @return a map of labels, key is language and value is translation 412 */ 413 protected Map<String, String> _getWorkflowLabels(String workflowName) 414 { 415 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 416 _workflowRightHelper.checkEditRight(workflowDescriptor); 417 Map<String, String> workflowLabelTranslations = _workflowSessionHelper.getWorkflowLabelTranslations(workflowName); 418 if (workflowLabelTranslations.isEmpty() && ArrayUtils.contains(_workflowHelper.getWorkflowNames(), workflowName)) 419 { 420 I18nizableText workflowI18nLabelKey = _workflowHelper.getWorkflowLabel(workflowName); 421 for (String language : _workflowLanguageManager.getLanguages()) 422 { 423 workflowLabelTranslations.put(language, StringUtils.defaultString(_i18nUtils.translate(workflowI18nLabelKey, language))); 424 } 425 } 426 return workflowLabelTranslations; 427 } 428 429 /** 430 * Get workflow infos 431 * @param workflowName the name of the workflow 432 * @return a map of the list of workflow names and the workflow's labels 433 */ 434 @Callable(rights = {"Workflow_Right_Edit", "Workflow_Right_Edit_User"}) 435 public Map<String, Object> getWorkflowInfos(String workflowName) 436 { 437 Map<String, Object> workflowInfos = new HashMap<>(); 438 workflowInfos.put("workflowNames", _workflowSessionHelper.getWorkflowNames()); 439 if (StringUtils.isNotBlank(workflowName)) 440 { 441 workflowInfos.put("labels", _getWorkflowLabels(workflowName)); 442 } 443 return workflowInfos; 444 } 445 446 /** 447 * Create a new workflow 448 * @param labels the multilingual labels 449 * @param id the unique name 450 * @return map of error message or workflow name if creation went well 451 */ 452 @Callable(rights = Callable.SKIP_BUILTIN_CHECK) 453 public Map<String, Object> createWorkflow(Map<String, String> labels, String id) 454 { 455 _workflowRightHelper.checkEditRight(); 456 Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames(); 457 if (workflowNames.contains(id)) 458 { 459 return Map.of("message", "duplicate-id"); 460 } 461 462 //create workflow descriptor 463 DescriptorFactory factory = new DescriptorFactory(); 464 WorkflowDescriptor workflowDescriptor = factory.createWorkflowDescriptor(); 465 workflowDescriptor.setName(id); 466 workflowDescriptor.getMetaAttributes().put("user", true); 467 468 //add meta to new workflow to prevent reinit 469 workflowDescriptor.getMetaAttributes().put(META_NEW_WORKFLOW, true); 470 471 //save workflow in session 472 _workflowSessionHelper.updateWorkflowDescriptor(workflowDescriptor); 473 _workflowSessionHelper.updateWorkflowNames(workflowDescriptor); 474 475 //add workflow label translations 476 _workflowSessionHelper.updateTranslations(id, _i18nHelper.getWorkflowLabelKey(id), labels); 477 478 return Map.of("workflowId", id); 479 } 480 481 /** 482 * Rename the workflow 483 * @param workflowName unique name of current workflow 484 * @param labels the new multilingual labels 485 * @return the workflow name 486 */ 487 @Callable(rights = Callable.SKIP_BUILTIN_CHECK) 488 public Map<String, Object> renameWorkflow(String workflowName, Map<String, String> labels) 489 { 490 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false); 491 _workflowRightHelper.checkEditRight(workflowDescriptor); 492 //create i18n entry 493 I18nizableText labelKey = _i18nHelper.getWorkflowLabelKey(workflowName); 494 _workflowSessionHelper.updateTranslations(workflowName, labelKey, labels); 495 return Map.of("workflowId", workflowName); 496 } 497 498 /** 499 * Duplicate a workflow 500 * @param workflowName the workflow to duplicate's name 501 * @param labels the new labels for the workflow 502 * @param duplicateId the new id for the clone 503 * @return map of results 504 */ 505 @Callable(rights = Callable.SKIP_BUILTIN_CHECK) 506 public Map<String, Object> duplicateWorkflow(String workflowName, Map<String, String> labels, String duplicateId) 507 { 508 _workflowRightHelper.checkEditRight(); 509 Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames(); 510 if (workflowNames.contains(workflowName)) 511 { 512 return Map.of("message", "duplicate-id"); 513 } 514 515 WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(duplicateId, false); 516 if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW)) 517 { 518 return Map.of("message", "no-save"); 519 } 520 521 _workflowSessionHelper.duplicateWorkflow(workflowName, labels, duplicateId); 522 523 return Map.of("workflowId", workflowName); 524 } 525 526}