001/* 002 * Copyright 2010 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.web.skin; 017 018import java.io.BufferedReader; 019import java.io.IOException; 020import java.io.InputStream; 021import java.io.InputStreamReader; 022import java.net.URL; 023import java.nio.file.FileSystem; 024import java.nio.file.Files; 025import java.nio.file.Path; 026import java.util.ArrayList; 027import java.util.Enumeration; 028import java.util.HashMap; 029import java.util.HashSet; 030import java.util.List; 031import java.util.Map; 032import java.util.Set; 033import java.util.stream.Stream; 034 035import org.apache.avalon.framework.CascadingRuntimeException; 036import org.apache.avalon.framework.activity.Disposable; 037import org.apache.avalon.framework.activity.Initializable; 038import org.apache.avalon.framework.component.Component; 039import org.apache.avalon.framework.configuration.Configuration; 040import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder; 041import org.apache.avalon.framework.context.Context; 042import org.apache.avalon.framework.context.ContextException; 043import org.apache.avalon.framework.context.Contextualizable; 044import org.apache.avalon.framework.logger.AbstractLogEnabled; 045import org.apache.avalon.framework.service.ServiceException; 046import org.apache.avalon.framework.service.ServiceManager; 047import org.apache.avalon.framework.service.Serviceable; 048import org.apache.avalon.framework.thread.ThreadSafe; 049import org.apache.cocoon.Constants; 050import org.apache.cocoon.components.ContextHelper; 051import org.apache.cocoon.environment.Request; 052import org.apache.commons.lang3.StringUtils; 053import org.apache.excalibur.source.Source; 054import org.apache.excalibur.source.SourceNotFoundException; 055import org.apache.excalibur.source.SourceResolver; 056 057import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 058import org.ametys.cms.contenttype.ContentTypesHelper; 059import org.ametys.core.util.JarFSManager; 060import org.ametys.runtime.servlet.RuntimeConfig; 061import org.ametys.runtime.servlet.RuntimeServlet; 062import org.ametys.web.WebConstants; 063import org.ametys.web.WebHelper; 064import org.ametys.web.data.type.ModelItemTypeExtensionPoint; 065import org.ametys.web.parameters.ViewAndParametersParser; 066import org.ametys.web.parameters.view.ViewParametersManager; 067import org.ametys.web.repository.site.SiteManager; 068 069/** 070 * Manages the skins 071 */ 072public class SkinsManager extends AbstractLogEnabled implements ThreadSafe, Serviceable, Component, Contextualizable, Initializable, Disposable 073{ 074 /** The avalon role name */ 075 public static final String ROLE = SkinsManager.class.getName(); 076 077 /** A cache of skins objects classified by absolute path */ 078 protected Map<Path, Skin> _skins = new HashMap<>(); 079 080 /** The skins declared as external: name of the skin and file location */ 081 protected Map<String, Path> _externalSkins = new HashMap<>(); 082 083 /** The skins declared in jar files */ 084 protected Map<String, Path> _resourcesSkins = new HashMap<>(); 085 086 /** Versions for bundled skins */ 087 protected Map<String, String> _versions = new HashMap<>(); 088 089 /** The avalon service manager */ 090 protected ServiceManager _manager; 091 /** The excalibur source resolver */ 092 protected SourceResolver _sourceResolver; 093 /** Avalon context */ 094 protected Context _context; 095 /** Cocoon context */ 096 protected org.apache.cocoon.environment.Context _cocoonContext; 097 /** The site manager */ 098 protected SiteManager _siteManager; 099 /** The view parameters manager */ 100 protected ViewParametersManager _viewParametersManager; 101 /** The view and parameters parser */ 102 protected ViewAndParametersParser _viewAndParametersParser; 103 /** The extension point for type of view parameter */ 104 protected ModelItemTypeExtensionPoint _viewParametersEP; 105 /** The skin configuration helper */ 106 protected SkinConfigurationHelper _skinConfigurationHelper; 107 /** The content type extension point */ 108 protected ContentTypeExtensionPoint _contentTypeExtensionPoint; 109 /** The content types helper */ 110 protected ContentTypesHelper _contentTypesHelper; 111 112 @Override 113 public void service(ServiceManager manager) throws ServiceException 114 { 115 _manager = manager; 116 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 117 _skinConfigurationHelper = (SkinConfigurationHelper) manager.lookup(SkinConfigurationHelper.ROLE); 118 } 119 120 @Override 121 public void contextualize(Context context) throws ContextException 122 { 123 _context = context; 124 _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT); 125 } 126 127 ServiceManager getServiceManager() 128 { 129 return _manager; 130 } 131 132 Context getContext() 133 { 134 return _context; 135 } 136 137 SourceResolver getSourceResolver() 138 { 139 return _sourceResolver; 140 } 141 142 ViewParametersManager getViewParametersManager() 143 { 144 if (_viewParametersManager == null) 145 { 146 try 147 { 148 _viewParametersManager = (ViewParametersManager) _manager.lookup(ViewParametersManager.ROLE); 149 } 150 catch (ServiceException e) 151 { 152 throw new RuntimeException(e); 153 } 154 } 155 return _viewParametersManager; 156 } 157 158 ViewAndParametersParser getViewAndParametersParser() 159 { 160 if (_viewAndParametersParser == null) 161 { 162 try 163 { 164 _viewAndParametersParser = (ViewAndParametersParser) _manager.lookup(ViewAndParametersParser.ROLE); 165 } 166 catch (ServiceException e) 167 { 168 throw new RuntimeException(e); 169 } 170 } 171 return _viewAndParametersParser; 172 } 173 174 ModelItemTypeExtensionPoint getViewParameterTypeExtensionPoint() 175 { 176 if (_viewParametersEP == null) 177 { 178 try 179 { 180 _viewParametersEP = (ModelItemTypeExtensionPoint) _manager.lookup(ModelItemTypeExtensionPoint.ROLE_VIEW_PARAM); 181 } 182 catch (ServiceException e) 183 { 184 throw new RuntimeException(e); 185 } 186 } 187 return _viewParametersEP; 188 } 189 190 public void initialize() throws Exception 191 { 192 // Skins can be in external locations 193 _listExternalSkins("context://" + RuntimeServlet.EXTERNAL_LOCATIONS); 194 195 // Skins can be in jars 196 _listResourcesSkins(); 197 } 198 199 public void dispose() 200 { 201 for (Skin skin : _skins.values()) 202 { 203 skin.dispose(); 204 } 205 _skins.clear(); 206 } 207 208 private void _listResourcesSkins() throws IOException 209 { 210 Enumeration<URL> skinResources = getClass().getClassLoader().getResources("META-INF/ametys-skins"); 211 212 while (skinResources.hasMoreElements()) 213 { 214 URL skinResource = skinResources.nextElement(); 215 216 try (InputStream is = skinResource.openStream(); 217 BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"))) 218 { 219 String skin; 220 while ((skin = br.readLine()) != null) 221 { 222 String skinNameAndVersion = StringUtils.substringBefore(skin, ':'); 223 String skinName = StringUtils.substringBefore(skinNameAndVersion, '@'); 224 String version = StringUtils.trimToNull(StringUtils.substringAfter(skinNameAndVersion, '@')); 225 String skinResourceURI = StringUtils.substringAfter(skin, ':'); 226 227 FileSystem skinFileSystem = JarFSManager.getInstance().getFileSystemByResource(skinResourceURI); 228 Path skinPath = skinFileSystem.getPath(skinResourceURI); 229 230 if (_isASkinPath(skinPath)) 231 { 232 _resourcesSkins.put(skinName, skinPath); 233 _versions.put(skinName, version); 234 } 235 else 236 { 237 getLogger().error("The skin '" + skinName + "' declared in a JAR file will be ignored as it is not a true skin"); 238 } 239 } 240 } 241 } 242 } 243 244 private void _listExternalSkins(String uri) throws Exception 245 { 246 Configuration externalConf; 247 248 // Read file 249 Source externalLocation = null; 250 try 251 { 252 externalLocation = _sourceResolver.resolveURI(uri); 253 if (!externalLocation.exists()) 254 { 255 throw new SourceNotFoundException("No file at " + uri); 256 } 257 258 DefaultConfigurationBuilder externalConfBuilder = new DefaultConfigurationBuilder(); 259 try (InputStream external = externalLocation.getInputStream()) 260 { 261 externalConf = externalConfBuilder.build(external, uri); 262 } 263 } 264 catch (SourceNotFoundException e) 265 { 266 getLogger().debug("No external location file"); 267 return; 268 } 269 finally 270 { 271 _sourceResolver.release(externalLocation); 272 } 273 274 // Apply file 275 for (Configuration skinConf : externalConf.getChild("skins").getChildren("skin")) 276 { 277 String name = skinConf.getAttribute("name", null); 278 String location = skinConf.getValue(null); 279 280 if (name != null && location != null) 281 { 282 Path skinDir = _getPath(location); 283 // Do not check at this time, since it is not read-only, this can change 284 _externalSkins.put(name, skinDir); 285 } 286 } 287 } 288 289 /* 290 * Returns the corresponding file, either absolute or relative to the context path 291 */ 292 private Path _getPath(String path) 293 { 294 if (path == null) 295 { 296 return null; 297 } 298 299 Path directory = Path.of(path); 300 if (directory.isAbsolute()) 301 { 302 return directory; 303 } 304 else 305 { 306 return Path.of(_cocoonContext.getRealPath("/" + path)); 307 } 308 } 309 310 /** 311 * Get the list of existing skins from JARs 312 * @return A set of skin names. Can be null if there is an error. 313 */ 314 public Set<String> getResourceSkins() 315 { 316 return _resourcesSkins.keySet(); 317 } 318 /** 319 * Get the list of existing skins 320 * @return A set of skin names. Can be null if there is an error. 321 */ 322 public Set<String> getSkins() 323 { 324 try 325 { 326 Set<String> skins = new HashSet<>(); 327 328 // JAR skins 329 skins.addAll(_resourcesSkins.keySet()); 330 331 // External skins 332 _externalSkins.entrySet().stream() 333 .filter(e -> _isASkinPath(e.getValue())) 334 .map(Map.Entry::getKey) 335 .forEach(skins::add); 336 337 // Skins at location 338 Path skinsDir = getLocalSkinsLocation(); 339 if (Files.exists(skinsDir) && Files.isDirectory(skinsDir)) 340 { 341 try (Stream<Path> files = Files.list(skinsDir)) 342 { 343 files.filter(this::_isASkinPath) 344 .map(p -> p.getFileName().toString()) 345 .forEach(skins::add); 346 } 347 } 348 349 return skins; 350 } 351 catch (Exception e) 352 { 353 getLogger().error("Can not determine the list of skins available", e); 354 return null; 355 } 356 } 357 358 /** 359 * Get the skin version or null if unknown. Only available for bundled skins. 360 * @param skinName the skin name 361 * @return the skin version if known. 362 */ 363 public String getVersion(String skinName) 364 { 365 return _versions.get(skinName); 366 } 367 368 /** 369 * Get a skin 370 * @param id The id of the skin 371 * @return The skin or null if the skin does not exist 372 */ 373 public Skin getSkin(String id) 374 { 375 if (id == null) 376 { 377 return null; 378 } 379 380 Path skinPath; 381 boolean modifiable = false; 382 383 skinPath = _resourcesSkins.get(id); 384 if (skinPath == null) 385 { 386 skinPath = _externalSkins.get(id); 387 if (skinPath == null) 388 { 389 skinPath = getLocalSkinsLocation().resolve(id); 390 if (Files.exists(skinPath) && Files.isDirectory(skinPath)) 391 { 392 modifiable = true; 393 } 394 else 395 { 396 // No skin with this name 397 return null; 398 } 399 } 400 } 401 402 if (!_isASkinPath(skinPath)) 403 { 404 // A skin with this name but is not a skin 405 if (_skins.containsKey(skinPath)) 406 { 407 Skin skin = _skins.get(skinPath); 408 skin.dispose(); 409 } 410 411 _skins.put(skinPath, null); 412 return null; 413 } 414 415 Skin skin = _skins.get(skinPath); 416 if (skin == null) 417 { 418 skin = new Skin(id, skinPath, modifiable, this, _skinConfigurationHelper); 419 _skins.put(skinPath, skin); 420 } 421 422 skin.refreshValues(); 423 return skin; 424 } 425 426 /** 427 * Get the skin and recursively its parents skins in the right weight order 428 * @param skin The non null skin to check 429 * @return The list of parent skins INCLUDING the given one 430 */ 431 public List<Skin> getSkinAndParents(Skin skin) 432 { 433 List<Skin> skinAndParents = new ArrayList<>(); 434 435 skinAndParents.add(skin); 436 437 for (String parentSkinId : skin.getParents()) 438 { 439 Skin parentSkin = getSkin(parentSkinId); 440 if (parentSkin == null) 441 { 442 throw new IllegalStateException("The skin '" + skin.getId() + "' extends the unexisting skin '" + parentSkinId + "'"); 443 } 444 skinAndParents.addAll(getSkinAndParents(parentSkin)); 445 } 446 447 return skinAndParents; 448 } 449 450 /** 451 * Compute the skin that are directly derived from the given one 452 * @param skin A non null skin 453 * @return A non null list of skins 454 */ 455 public List<Skin> getDirectChildren(Skin skin) 456 { 457 List<Skin> children = new ArrayList<>(); 458 459 for (String otherSkinId : getSkins()) 460 { 461 if (!otherSkinId.equals(skin.getId())) 462 { 463 Skin otherSkin = getSkin(otherSkinId); 464 if (otherSkin.getParents().contains(skin.getId())) 465 { 466 children.add(otherSkin); 467 } 468 } 469 } 470 471 return children; 472 } 473 474 /** 475 * Get the skins location 476 * @return the skin location 477 */ 478 public Path getLocalSkinsLocation() 479 { 480 try 481 { 482 Request request = ContextHelper.getRequest(_context); 483 String skinLocation = (String) request.getAttribute("skin-location"); 484 if (skinLocation != null) 485 { 486 return Path.of(RuntimeConfig.getInstance().getAmetysHome().getAbsolutePath(), skinLocation); 487 } 488 } 489 catch (CascadingRuntimeException e) 490 { 491 // Ignore 492 } 493 494 return Path.of(_cocoonContext.getRealPath("/skins")); 495 } 496 497 /** 498 * Get the skin name from request or <code>null</code> if not found 499 * @return The skin name or <code>null</code> 500 */ 501 public String getSkinNameFromRequest () 502 { 503 Request request = null; 504 try 505 { 506 request = ContextHelper.getRequest(_context); 507 508 return getSkinNameFromRequest(request); 509 } 510 catch (RuntimeException e) 511 { 512 // No running request 513 return null; 514 } 515 } 516 517 /** 518 * Get the skin name from request or <code>null</code> if not found 519 * @param request The request 520 * @return The skin name or <code>null</code> 521 */ 522 public String getSkinNameFromRequest(Request request) 523 { 524 if (_siteManager == null) 525 { 526 try 527 { 528 _siteManager = (SiteManager) _manager.lookup(SiteManager.ROLE); 529 } 530 catch (ServiceException e) 531 { 532 throw new IllegalStateException(e); 533 } 534 } 535 536 if (request == null) 537 { 538 return null; 539 } 540 541 // First, search the skin name in the request attributes. 542 String skinName = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SKIN_ID); 543 544 // Then, test if the site name is present as a request attribute to deduce the skin name. 545 if (StringUtils.isEmpty(skinName)) 546 { 547 String siteName = WebHelper.getSiteName(request); 548 549 if (StringUtils.isNotEmpty(siteName)) 550 { 551 skinName = _siteManager.getSite(siteName).getSkinId(); 552 } 553 } 554 555 return skinName; 556 } 557 558 private boolean _isASkinPath(Path skinDir) 559 { 560 if (!Files.exists(skinDir) || !Files.isDirectory(skinDir)) 561 { 562 return false; 563 } 564 565 Path templateDir = skinDir.resolve(Skin.TEMPLATES_PATH); 566 Path confDir = skinDir.resolve(Skin.CONF_PATH); 567 if ((!Files.exists(templateDir) || !Files.isDirectory(templateDir)) 568 && (!Files.exists(confDir) || Files.isDirectory(confDir))) 569 { 570 return false; 571 } 572 573 return true; 574 } 575}