001/* 002 * Copyright 2015 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.io.OutputStream; 023import java.net.URL; 024import java.nio.file.FileSystem; 025import java.nio.file.Files; 026import java.nio.file.Path; 027import java.util.Enumeration; 028import java.util.HashMap; 029import java.util.HashSet; 030import java.util.Map; 031import java.util.Properties; 032import java.util.Set; 033import java.util.stream.Stream; 034 035import javax.xml.transform.OutputKeys; 036import javax.xml.transform.TransformerConfigurationException; 037import javax.xml.transform.TransformerFactory; 038import javax.xml.transform.sax.SAXTransformerFactory; 039import javax.xml.transform.sax.TransformerHandler; 040import javax.xml.transform.stream.StreamResult; 041import javax.xml.xpath.XPath; 042import javax.xml.xpath.XPathExpressionException; 043import javax.xml.xpath.XPathFactory; 044 045import org.apache.avalon.framework.activity.Initializable; 046import org.apache.avalon.framework.component.Component; 047import org.apache.avalon.framework.configuration.Configuration; 048import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder; 049import org.apache.avalon.framework.context.Context; 050import org.apache.avalon.framework.context.ContextException; 051import org.apache.avalon.framework.context.Contextualizable; 052import org.apache.avalon.framework.logger.AbstractLogEnabled; 053import org.apache.avalon.framework.service.ServiceException; 054import org.apache.avalon.framework.service.ServiceManager; 055import org.apache.avalon.framework.service.Serviceable; 056import org.apache.avalon.framework.thread.ThreadSafe; 057import org.apache.cocoon.Constants; 058import org.apache.cocoon.util.HashUtil; 059import org.apache.cocoon.xml.XMLUtils; 060import org.apache.excalibur.source.Source; 061import org.apache.excalibur.source.SourceNotFoundException; 062import org.apache.excalibur.source.SourceResolver; 063import org.xml.sax.InputSource; 064import org.xml.sax.SAXException; 065import org.xml.sax.helpers.AttributesImpl; 066 067import org.ametys.core.util.JarFSManager; 068import org.ametys.runtime.servlet.RuntimeServlet; 069 070/** 071 * Manages the models of skin 072 */ 073public class SkinModelsManager extends AbstractLogEnabled implements ThreadSafe, Serviceable, Component, Contextualizable, Initializable 074{ 075 /** The avalon role name */ 076 public static final String ROLE = SkinModelsManager.class.getName(); 077 078 /** The set of templates classified by skins */ 079 protected Map<Path, SkinModel> _models = new HashMap<>(); 080 081 /** The models declared as external: name of the models and file location */ 082 protected Map<String, Path> _externalSkinModels = new HashMap<>(); 083 084 /** The models declared in jar files */ 085 protected Map<String, Path> _resourcesSkinModels = new HashMap<>(); 086 087 /** The avalon service manager */ 088 protected ServiceManager _manager; 089 /** The excalibur source resolver */ 090 protected SourceResolver _sourceResolver; 091 /** The sites manager */ 092 protected SkinsManager _skinsManager; 093 /** Avalon context */ 094 protected Context _context; 095 /** Cocoon context */ 096 protected org.apache.cocoon.environment.Context _cocoonContext; 097 098 @Override 099 public void service(ServiceManager manager) throws ServiceException 100 { 101 _manager = manager; 102 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 103 _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE); 104 } 105 106 @Override 107 public void contextualize(Context context) throws ContextException 108 { 109 _context = context; 110 _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT); 111 } 112 113 public void initialize() throws Exception 114 { 115 // Skins can be in external locations 116 _listExternalSkinModels("context://" + RuntimeServlet.EXTERNAL_LOCATIONS); 117 118 // Skins can be in jars 119 _listResourcesSkinModels(); 120 } 121 private void _listResourcesSkinModels() throws IOException 122 { 123 Enumeration<URL> skinModelResources = getClass().getClassLoader().getResources("META-INF/ametys-models"); 124 125 while (skinModelResources.hasMoreElements()) 126 { 127 URL skinModelResource = skinModelResources.nextElement(); 128 129 try (InputStream is = skinModelResource.openStream(); 130 BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"))) 131 { 132 String skinModel; 133 while ((skinModel = br.readLine()) != null) 134 { 135 int i = skinModel.indexOf(':'); 136 if (i != -1) 137 { 138 String skinModelName = skinModel.substring(0, i); 139 String skinModelResourceURI = skinModel.substring(i + 1); 140 141 FileSystem skinFileSystem = JarFSManager.getInstance().getFileSystemByResource(skinModelResourceURI); 142 Path skinModelPath = skinFileSystem.getPath(skinModelResourceURI); 143 144 if (_isASkinModelPath(skinModelPath)) 145 { 146 _resourcesSkinModels.put(skinModelName, skinModelPath); 147 } 148 else 149 { 150 getLogger().error("The skin model '" + skinModelName + "' declared in a JAR file will be ignored as it is not a true model"); 151 } 152 } 153 } 154 } 155 } 156 } 157 158 private void _listExternalSkinModels(String uri) throws Exception 159 { 160 Configuration externalConf; 161 162 // Read file 163 Source externalLocation = null; 164 try 165 { 166 externalLocation = _sourceResolver.resolveURI(uri); 167 if (!externalLocation.exists()) 168 { 169 throw new SourceNotFoundException("No file at " + uri); 170 } 171 172 DefaultConfigurationBuilder externalConfBuilder = new DefaultConfigurationBuilder(); 173 try (InputStream external = externalLocation.getInputStream()) 174 { 175 externalConf = externalConfBuilder.build(external, uri); 176 } 177 } 178 catch (SourceNotFoundException e) 179 { 180 getLogger().debug("No external location file"); 181 return; 182 } 183 finally 184 { 185 _sourceResolver.release(externalLocation); 186 } 187 188 // Apply file 189 for (Configuration skinModelConf : externalConf.getChild("models").getChildren("model")) 190 { 191 String name = skinModelConf.getAttribute("name", null); 192 String location = skinModelConf.getValue(null); 193 194 if (name != null && location != null) 195 { 196 Path skinModelDir = _getPath(location); 197 // Do not check at this time, since it is not read-only, this can change 198 _externalSkinModels.put(name, skinModelDir); 199 } 200 } 201 } 202 203 /* 204 * Returns the corresponding file, either absolute or relative to the context path 205 */ 206 private Path _getPath(String path) 207 { 208 if (path == null) 209 { 210 return null; 211 } 212 213 Path directory = Path.of(path); 214 if (directory.isAbsolute()) 215 { 216 return directory; 217 } 218 else 219 { 220 return Path.of(_cocoonContext.getRealPath("/" + path)); 221 } 222 } 223 224 225 /** 226 * Get the list of existing models 227 * @return A set of model names. Can be null if there is an error. 228 */ 229 public Set<String> getModels() 230 { 231 try 232 { 233 Set<String> skinModels = new HashSet<>(); 234 235 // JAR skins 236 skinModels.addAll(_resourcesSkinModels.keySet()); 237 238 // External skins 239 _externalSkinModels.entrySet().stream() 240 .filter(e -> _isASkinModelPath(e.getValue())) 241 .map(Map.Entry::getKey) 242 .forEach(skinModels::add); 243 244 // Skins at location 245 Path skinModelsDir = getLocalModelsLocation(); 246 if (Files.exists(skinModelsDir) && Files.isDirectory(skinModelsDir)) 247 { 248 try (Stream<Path> files = Files.list(skinModelsDir)) 249 { 250 files.filter(this::_isASkinModelPath) 251 .map(p -> p.getFileName().toString()) 252 .forEach(skinModels::add); 253 } 254 } 255 256 return skinModels; 257 } 258 catch (Exception e) 259 { 260 getLogger().error("Can not determine the list of available models", e); 261 return null; 262 } 263 } 264 265 /** 266 * Get a model 267 * @param id The id of the model 268 * @return The model or null if the model does not exists 269 */ 270 public SkinModel getModel(String id) 271 { 272 Path skinModelPath; 273 boolean modifiable = false; 274 275 skinModelPath = _resourcesSkinModels.get(id); 276 if (skinModelPath == null) 277 { 278 skinModelPath = _externalSkinModels.get(id); 279 if (skinModelPath == null) 280 { 281 skinModelPath = getLocalModelsLocation().resolve(id); 282 if (Files.exists(skinModelPath) && Files.isDirectory(skinModelPath)) 283 { 284 modifiable = true; 285 } 286 else 287 { 288 // No skin with this name 289 return null; 290 } 291 } 292 } 293 294 if (!_isASkinModelPath(skinModelPath)) 295 { 296 // A model with this name but is not a model 297 _models.put(skinModelPath, null); 298 return null; 299 } 300 301 SkinModel skinModel = _models.get(skinModelPath); 302 if (skinModel == null) 303 { 304 skinModel = new SkinModel(id, skinModelPath, modifiable); 305 _models.put(skinModelPath, skinModel); 306 } 307 308 skinModel.refreshValues(); 309 return skinModel; 310 } 311 312 /** 313 * Get the id of model associated to a skin 314 * @param skin The skin 315 * @return The id of the model or <code>null</code> if there is no model for this skin 316 */ 317 public String getModelOfSkin(Skin skin) 318 { 319 Path modelFile = skin.getRawPath().resolve("model.xml"); // Do not support model by inheritance, since unlinking the model or applying it will be local 320 if (!Files.exists(modelFile)) 321 { 322 // No model 323 return null; 324 } 325 326 try (InputStream is = Files.newInputStream(modelFile)) 327 { 328 XPath xpath = XPathFactory.newInstance().newXPath(); 329 return xpath.evaluate("model/@id", new InputSource(is)); 330 } 331 catch (XPathExpressionException e) 332 { 333 throw new IllegalStateException("The id of model is missing", e); 334 } 335 catch (IOException e) 336 { 337 getLogger().error("Can not determine the model of the skin", e); 338 return null; 339 } 340 341 } 342 /** 343 * Get hash from model 344 * @param id The id of the model 345 * @return unique has 346 */ 347 public String getModelHash (String id) 348 { 349 SkinModel model = getModel(id); 350 Path prefix = model.getPath().getParent(); 351 352 StringBuffer sb = new StringBuffer(); 353 354 try 355 { 356 Files.walk(model.getPath()) 357 .forEach(child -> 358 { 359 try 360 { 361 sb.append(prefix.relativize(child).toString()) 362 .append("-") 363 .append(Files.getLastModifiedTime(child).toMillis()).append(";"); 364 } 365 catch (IOException e) 366 { 367 throw new RuntimeException("Cannot compute model hash for " + id, e); 368 } 369 }); 370 } 371 catch (IOException e) 372 { 373 throw new RuntimeException("Cannot compute model hash for " + id, e); 374 } 375 376 long hash = Math.abs(HashUtil.hash(sb)); 377 return Long.toString(hash, 64); 378 } 379 380 /** 381 * Generates the model.xml file for the skin 382 * @param skinDir The skin directory 383 * @param modelId The model id 384 * @param colorTheme The id of color theme. Can be null. 385 * @throws IOException if an I/O exception occurs during generation 386 * @throws SAXException if an error occurs during generation 387 * @throws TransformerConfigurationException if an error occurs during generation 388 */ 389 public void generateModelFile (Path skinDir, String modelId, String colorTheme) throws IOException, SAXException, TransformerConfigurationException 390 { 391 Path modelFile = skinDir.resolve("model.xml"); 392 try (OutputStream os = Files.newOutputStream(modelFile)) 393 { 394 // create a transformer for saving sax into a file 395 TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler(); 396 // create the result where to write 397 StreamResult sResult = new StreamResult(os); 398 th.setResult(sResult); 399 400 // create the format of result 401 Properties format = new Properties(); 402 format.put(OutputKeys.METHOD, "xml"); 403 format.put(OutputKeys.INDENT, "yes"); 404 format.put(OutputKeys.ENCODING, "UTF-8"); 405 th.getTransformer().setOutputProperties(format); 406 407 // Send SAX events 408 th.startDocument(); 409 410 // Hash for model 411 String hash = getModelHash(modelId); 412 413 AttributesImpl attrs = new AttributesImpl(); 414 attrs.addAttribute("", "id", "id", "CDATA", modelId); 415 attrs.addAttribute("", "hash", "hash", "CDATA", hash); 416 XMLUtils.startElement(th, "model", attrs); 417 418 XMLUtils.createElement(th, "parameters", "\n"); 419 420 if (colorTheme != null) 421 { 422 XMLUtils.createElement(th, "color-theme", colorTheme); 423 } 424 425 XMLUtils.endElement(th, "model"); 426 427 th.endDocument(); 428 } 429 } 430 431 /** 432 * Generates the model.xml file for the skin 433 * @param skinDir The skin directory 434 * @param modelId The model id 435 * @throws IOException if an I/O exception occurs during generation 436 * @throws SAXException if an error occurs during generation 437 * @throws TransformerConfigurationException if an error occurs during generation 438 */ 439 public void generateModelFile (Path skinDir, String modelId) throws IOException, SAXException, TransformerConfigurationException 440 { 441 generateModelFile(skinDir, modelId, null); 442 } 443 444 /** 445 * Get the skins location 446 * @return the skin location 447 */ 448 public Path getLocalModelsLocation () 449 { 450 return Path.of(_cocoonContext.getRealPath("/models")); 451 } 452 453 private boolean _isASkinModelPath(Path modelDir) 454 { 455 if (!Files.exists(modelDir) || !Files.isDirectory(modelDir)) 456 { 457 return false; 458 } 459 460 Path model = modelDir.resolve("model"); 461 return Files.exists(model) && Files.isDirectory(model); 462 } 463}