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.plugins.linkdirectory; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.text.Normalizer; 021import java.util.ArrayList; 022import java.util.Arrays; 023import java.util.Collections; 024import java.util.Comparator; 025import java.util.HashMap; 026import java.util.Iterator; 027import java.util.List; 028import java.util.Map; 029import java.util.regex.Pattern; 030import java.util.stream.Collectors; 031 032import org.apache.avalon.framework.activity.Initializable; 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.configuration.Configuration; 035import org.apache.avalon.framework.configuration.ConfigurationException; 036import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder; 037import org.apache.avalon.framework.context.Context; 038import org.apache.avalon.framework.context.ContextException; 039import org.apache.avalon.framework.context.Contextualizable; 040import org.apache.avalon.framework.service.ServiceException; 041import org.apache.avalon.framework.service.ServiceManager; 042import org.apache.avalon.framework.service.Serviceable; 043import org.apache.cocoon.components.ContextHelper; 044import org.apache.cocoon.environment.Request; 045import org.apache.cocoon.xml.AttributesImpl; 046import org.apache.cocoon.xml.XMLUtils; 047import org.apache.commons.lang3.ArrayUtils; 048import org.apache.commons.lang3.StringUtils; 049import org.apache.commons.lang3.Strings; 050import org.apache.commons.lang3.tuple.ImmutablePair; 051import org.apache.commons.lang3.tuple.Pair; 052import org.apache.excalibur.source.Source; 053import org.apache.excalibur.source.SourceResolver; 054import org.apache.jackrabbit.util.ISO9075; 055import org.xml.sax.ContentHandler; 056import org.xml.sax.SAXException; 057 058import org.ametys.cms.data.Binary; 059import org.ametys.cms.tag.Tag; 060import org.ametys.core.ObservationConstants; 061import org.ametys.core.cache.AbstractCacheManager; 062import org.ametys.core.cache.Cache; 063import org.ametys.core.observation.Event; 064import org.ametys.core.observation.ObservationManager; 065import org.ametys.core.observation.Observer; 066import org.ametys.core.right.RightManager; 067import org.ametys.core.user.CurrentUserProvider; 068import org.ametys.core.user.UserIdentity; 069import org.ametys.core.userpref.UserPreferencesException; 070import org.ametys.core.userpref.UserPreferencesManager; 071import org.ametys.plugins.core.impl.cache.AbstractCacheKey; 072import org.ametys.plugins.explorer.resources.Resource; 073import org.ametys.plugins.linkdirectory.Link.LinkStatus; 074import org.ametys.plugins.linkdirectory.Link.LinkType; 075import org.ametys.plugins.linkdirectory.Link.LinkVisibility; 076import org.ametys.plugins.linkdirectory.dynamic.DynamicInformationProviderExtensionPoint; 077import org.ametys.plugins.linkdirectory.link.LinkDAO; 078import org.ametys.plugins.linkdirectory.repository.DefaultLink; 079import org.ametys.plugins.linkdirectory.repository.DefaultLinkFactory; 080import org.ametys.plugins.linkdirectory.theme.ThemeExpression; 081import org.ametys.plugins.linkdirectory.theme.ThemesDAO; 082import org.ametys.plugins.repository.AmetysObject; 083import org.ametys.plugins.repository.AmetysObjectIterable; 084import org.ametys.plugins.repository.AmetysObjectResolver; 085import org.ametys.plugins.repository.AmetysRepositoryException; 086import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 087import org.ametys.plugins.repository.TraversableAmetysObject; 088import org.ametys.plugins.repository.UnknownAmetysObjectException; 089import org.ametys.plugins.repository.query.expression.Expression; 090import org.ametys.runtime.i18n.I18nizableText; 091import org.ametys.runtime.plugin.component.AbstractLogEnabled; 092import org.ametys.web.WebConstants; 093import org.ametys.web.WebHelper; 094import org.ametys.web.repository.page.Page; 095import org.ametys.web.repository.site.Site; 096import org.ametys.web.repository.site.SiteManager; 097import org.ametys.web.skin.Skin; 098import org.ametys.web.skin.SkinConfigurationHelper; 099import org.ametys.web.skin.SkinsManager; 100import org.ametys.web.userpref.FOUserPreferencesConstants; 101 102/** 103 * Link directory helper. 104 */ 105public final class DirectoryHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable, Observer 106{ 107 /** The component role */ 108 public static final String ROLE = DirectoryHelper.class.getName(); 109 110 /** The user preference ordered link attribute name */ 111 public static final String USER_PREF_ORDERED_LINK_ATTR = "checked-links"; 112 113 /** The user preference hidden link attribute name */ 114 public static final String USER_PREF_HIDDEN_LINK_ATTR = "hidden-links"; 115 116 /** The path to the default configuration file */ 117 public static final String DEFAULT_CONF_FILE_PATH = "skin://conf/link-directory.xml"; 118 119 private static final String __PLUGIN_NODE_NAME = "linkdirectory"; 120 121 private static final String __LINKS_NODE_NAME = "ametys:directoryLinks"; 122 123 private static final String __USER_LINKS_NODE_NAME = "user-favorites"; 124 125 private static final String __RESTRICTIONS_CACHE = DirectoryHelper.class.getName() + "$restrictions.cache"; 126 private static final String __INTERNAL_URL_CACHE = DirectoryHelper.class.getName() + "$internal.url.cache"; 127 128 /** Themes DAO */ 129 protected ThemesDAO _themesDAO; 130 131 /** The Ametys object resolver */ 132 private AmetysObjectResolver _ametysObjectResolver; 133 134 /** The site manager */ 135 private SiteManager _siteManager; 136 137 /** The user preferences manager */ 138 private UserPreferencesManager _userPreferencesManager; 139 140 /** The current user provider */ 141 private CurrentUserProvider _currentUserProvider; 142 143 /** The right manager */ 144 private RightManager _rightManager; 145 146 /** The link DAO */ 147 private LinkDAO _linkDAO; 148 149 /** The context */ 150 private Context _context; 151 152 private DynamicInformationProviderExtensionPoint _dynamicProviderEP; 153 154 private SkinsManager _skinsManager; 155 156 private SkinConfigurationHelper _skinConfigurationHelper; 157 158 private ServiceManager _smanager; 159 160 private SourceResolver _sourceResolver; 161 162 private AbstractCacheManager _cacheManager; 163 164 private ObservationManager _observationManager; 165 166 @Override 167 public void service(ServiceManager manager) throws ServiceException 168 { 169 _smanager = manager; 170 _ametysObjectResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 171 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 172 _userPreferencesManager = (UserPreferencesManager) manager.lookup(UserPreferencesManager.ROLE + ".FO"); 173 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 174 _dynamicProviderEP = (DynamicInformationProviderExtensionPoint) manager.lookup(DynamicInformationProviderExtensionPoint.ROLE); 175 _rightManager = (RightManager) manager.lookup(RightManager.ROLE); 176 _linkDAO = (LinkDAO) manager.lookup(LinkDAO.ROLE); 177 _themesDAO = (ThemesDAO) manager.lookup(ThemesDAO.ROLE); 178 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 179 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 180 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 181 } 182 183 public void initialize() throws Exception 184 { 185 _cacheManager.createMemoryCache(__RESTRICTIONS_CACHE, 186 new I18nizableText("plugin.link-directory", "PLUGINS_LINK_DIRECTORY_CACHE_RESTRICTIONS_LABEL"), 187 new I18nizableText("plugin.link-directory", "PLUGINS_LINK_DIRECTORY_CACHE_RESTRICTIONS_DESCRIPTION"), 188 true, 189 null); 190 191 _cacheManager.createMemoryCache(__INTERNAL_URL_CACHE, 192 new I18nizableText("plugin.link-directory", "PLUGINS_LINK_DIRECTORY_CACHE_INTERNAL_URL_LABEL"), 193 new I18nizableText("plugin.link-directory", "PLUGINS_LINK_DIRECTORY_CACHE_INTERNAL_URL_DESCRIPTION"), 194 true, 195 null); 196 197 _observationManager.registerObserver(this); 198 199 } 200 201 public int getPriority() 202 { 203 return 0; 204 } 205 206 public boolean supports(Event event) 207 { 208 String eventId = event.getId(); 209 210 if (eventId.equals(ObservationConstants.EVENT_ACL_UPDATED)) 211 { 212 Object object = event.getArguments().get(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT); 213 return object != null && object instanceof DefaultLink; 214 } 215 else 216 { 217 return eventId.equals(DirectoryEvents.LINK_CREATED) 218 || eventId.equals(DirectoryEvents.LINK_MODIFIED) 219 || eventId.equals(DirectoryEvents.LINK_DELETED) 220 || eventId.equals(ObservationConstants.EVENT_ACL_UPDATED); 221 } 222 } 223 224 public void observe(Event event, Map<String, Object> transientVars) throws Exception 225 { 226 // FIXME Invalidate the cache by site 227 _getRestrictionsCache().invalidateAll(); 228 _getInternalUrlCache().invalidateAll(); 229 } 230 231 private SkinsManager _getSkinManager() 232 { 233 if (_skinsManager == null) 234 { 235 try 236 { 237 _skinsManager = (SkinsManager) _smanager.lookup(SkinsManager.ROLE); 238 } 239 catch (ServiceException e) 240 { 241 throw new IllegalArgumentException(e); 242 } 243 } 244 return _skinsManager; 245 } 246 247 private SkinConfigurationHelper _getSkinConfigurationHelper() 248 { 249 if (_skinConfigurationHelper == null) 250 { 251 try 252 { 253 _skinConfigurationHelper = (SkinConfigurationHelper) _smanager.lookup(SkinConfigurationHelper.ROLE); 254 } 255 catch (ServiceException e) 256 { 257 throw new IllegalArgumentException(e); 258 } 259 } 260 return _skinConfigurationHelper; 261 } 262 263 @Override 264 public void contextualize(Context context) throws ContextException 265 { 266 _context = context; 267 } 268 269 /** 270 * Get the root plugin storage object. 271 * @param site the site. 272 * @return the root plugin storage object. 273 * @throws AmetysRepositoryException if a repository error occurs. 274 */ 275 public ModifiableTraversableAmetysObject getPluginNode(Site site) throws AmetysRepositoryException 276 { 277 try 278 { 279 ModifiableTraversableAmetysObject pluginsNode = site.getRootPlugins(); 280 281 return getOrCreateNode(pluginsNode, __PLUGIN_NODE_NAME, "ametys:unstructured"); 282 } 283 catch (AmetysRepositoryException e) 284 { 285 throw new AmetysRepositoryException("Error getting the link directory plugin node for site " + site.getName(), e); 286 } 287 } 288 289 /** 290 * Get the links root node. 291 * @param site the site 292 * @param language the language. 293 * @return the links root node. 294 * @throws AmetysRepositoryException if a repository error occurs. 295 */ 296 public ModifiableTraversableAmetysObject getLinksNode(Site site, String language) throws AmetysRepositoryException 297 { 298 try 299 { 300 // Get the root plugin node. 301 ModifiableTraversableAmetysObject pluginNode = getPluginNode(site); 302 303 // Get or create the language node. 304 ModifiableTraversableAmetysObject langNode = getOrCreateNode(pluginNode, language, "ametys:unstructured"); 305 306 // Get or create the definitions container node in the language node and return it. 307 return getOrCreateNode(langNode, __LINKS_NODE_NAME, DefaultLinkFactory.LINK_ROOT_NODE_TYPE); 308 } 309 catch (AmetysRepositoryException e) 310 { 311 throw new AmetysRepositoryException("Error getting the link directory root node for site " + site.getName() + " and language " + language, e); 312 } 313 } 314 315 /** 316 * Get the links root node for the given user. 317 * @param site the site 318 * @param language the language. 319 * @param user The user identity 320 * @return the links root node for the given user. 321 * @throws AmetysRepositoryException if a repository error occurs. 322 */ 323 public ModifiableTraversableAmetysObject getLinksForUserNode(Site site, String language, UserIdentity user) throws AmetysRepositoryException 324 { 325 try 326 { 327 // Get the root plugin node. 328 ModifiableTraversableAmetysObject pluginNode = getPluginNode(site); 329 330 // Get or create the user links node. 331 ModifiableTraversableAmetysObject userLinksNode = getOrCreateNode(pluginNode, __USER_LINKS_NODE_NAME, "ametys:unstructured"); 332 // Get or create the population node. 333 ModifiableTraversableAmetysObject populationNode = getOrCreateNode(userLinksNode, user.getPopulationId(), "ametys:unstructured"); 334 // Get or create the login node. 335 ModifiableTraversableAmetysObject loginNode = getOrCreateNode(populationNode, user.getLogin(), "ametys:unstructured"); 336 // Get or create the language node. 337 ModifiableTraversableAmetysObject langNode = getOrCreateNode(loginNode, language, "ametys:unstructured"); 338 339 // Get or create the definitions container node in the language node and return it. 340 return getOrCreateNode(langNode, __LINKS_NODE_NAME, DefaultLinkFactory.LINK_ROOT_NODE_TYPE); 341 } 342 catch (AmetysRepositoryException e) 343 { 344 throw new AmetysRepositoryException("Error getting the link directory root node for user " + user + " and for site " + site.getName() + " and language " + language, e); 345 } 346 } 347 348 /** 349 * Get the plugin node path 350 * @param siteName the site name. 351 * @return the plugin node path. 352 */ 353 public String getPluginNodePath(String siteName) 354 { 355 return String.format("//element(%s, ametys:site)/ametys-internal:plugins/%s", siteName, __PLUGIN_NODE_NAME); 356 } 357 358 /** 359 * Get the links root node path 360 * @param siteName the site name. 361 * @param language the language 362 * @return the links root node path. 363 */ 364 public String getLinksNodePath(String siteName, String language) 365 { 366 return getPluginNodePath(siteName) + "/" + language + "/" + __LINKS_NODE_NAME; 367 } 368 369 /** 370 * Get the links root node path for the given user 371 * @param siteName the site name. 372 * @param language the language 373 * @param user The user identity 374 * @return the links root node path for the given user. 375 */ 376 public String getLinksForUserNodePath(String siteName, String language, UserIdentity user) 377 { 378 return getPluginNodePath(siteName) + "/" + __USER_LINKS_NODE_NAME + "/" + ISO9075.encode(user.getPopulationId()) + "/" + ISO9075.encode(user.getLogin()) + "/" + language + "/" + __LINKS_NODE_NAME; 379 } 380 381 /** 382 * Get all the links 383 * @param siteName the site name. 384 * @param language the language. 385 * @return all the links' nodes 386 */ 387 public String getAllLinksQuery(String siteName, String language) 388 { 389 return getLinksNodePath(siteName, language) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")"; 390 } 391 392 /** 393 * Get the link query corresponding to the expression passed as a parameter 394 * @param siteName the site name. 395 * @param language the language. 396 * @param expression the {@link Expression} of the links retrieval query 397 * @return the link corresponding to the expression passed as a parameter 398 */ 399 public String getLinksQuery(String siteName, String language, Expression expression) 400 { 401 return getLinksNodePath(siteName, language) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")[" + expression.build() + "]"; 402 } 403 404 /** 405 * Get the user link query corresponding to the expression passed as a parameter 406 * @param siteName the site name. 407 * @param language the language. 408 * @param user the user 409 * @param expression the {@link Expression} of the links retrieval query. Can be null to get all user links 410 * @return the user link corresponding to the expression passed as a parameter 411 */ 412 public String getUserLinksQuery(String siteName, String language, UserIdentity user, Expression expression) 413 { 414 String query = getLinksForUserNodePath(siteName, language, user) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")"; 415 if (expression != null) 416 { 417 query += "[" + expression.build() + "]"; 418 } 419 return query; 420 } 421 /** 422 * Get the query verifying the existence of an url 423 * @param siteName the site name. 424 * @param language the language. 425 * @param url the url to test. 426 * @return the query verifying the existence of an url 427 */ 428 public String getUrlExistsQuery(String siteName, String language, String url) 429 { 430 String lowerCaseUrl = Strings.CS.replace(url, "'", "''").toLowerCase(); 431 return getLinksNodePath(siteName, language) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")[fn:lower-case(@ametys-internal:url) = '" + lowerCaseUrl + "' or fn:lower-case(@ametys-internal:internal-url) = '" + lowerCaseUrl + "']"; 432 } 433 434 /** 435 * Get the query verifying the existence of an url for the given user 436 * @param siteName the site name. 437 * @param language the language. 438 * @param url the url to test. 439 * @param user The user identity 440 * @return the query verifying the existence of an url for the given user 441 */ 442 public String getUrlExistsForUserQuery(String siteName, String language, String url, UserIdentity user) 443 { 444 String lowerCaseUrl = Strings.CS.replace(url, "'", "''").toLowerCase(); 445 return getLinksForUserNodePath(siteName, language, user) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")[fn:lower-case(@ametys-internal:url) = '" + lowerCaseUrl + "' or fn:lower-case(@ametys-internal:internal-url) = '" + lowerCaseUrl + "']"; 446 } 447 448 /** 449 * Normalizes an input string in order to capitalize it, remove accents, and replace whitespaces with underscores 450 * @param s the string to normalize 451 * @return the normalized string 452 */ 453 public String normalizeString(String s) 454 { 455 // Strip accents 456 String normalizedLabel = Normalizer.normalize(s.toUpperCase(), Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", ""); 457 458 // Upper case 459 String upperCaseLabel = normalizedLabel.replaceAll(" +", "_").replaceAll("[^\\w-]", "_").replaceAll("_+", "_").toUpperCase(); 460 461 return upperCaseLabel; 462 } 463 464 /** 465 * Get links of a given site and language 466 * @param siteName the site name 467 * @param language the language 468 * @return the links 469 */ 470 public AmetysObjectIterable<DefaultLink> getLinks(String siteName, String language) 471 { 472 Site site = _siteManager.getSite(siteName); 473 TraversableAmetysObject linksNode = getLinksNode(site, language); 474 return linksNode.getChildren(); 475 } 476 477 /** 478 * Get the list of links corresponding to the given theme ids 479 * @param themesIds the ids of the configured themes 480 * @param siteName the site's name 481 * @param language the site's language 482 * @return the list of default links corresponding to the given themes 483 */ 484 public List<DefaultLink> getLinks(List<String> themesIds, String siteName, String language) 485 { 486 Site site = _siteManager.getSite(siteName); 487 TraversableAmetysObject linksNode = getLinksNode(site, language); 488 AmetysObjectIterable<DefaultLink> links = linksNode.getChildren(); 489 490 return links.stream() 491 .filter(l -> themesIds.isEmpty() || !Collections.disjoint(Arrays.asList(l.getThemes()), themesIds)) 492 .collect(Collectors.toList()); 493 } 494 495 /** 496 * Get links of a given site and language, for the given user 497 * @param siteName the site name 498 * @param language the language 499 * @param user The user identity 500 * @return the links for the given user 501 */ 502 public AmetysObjectIterable<DefaultLink> getUserLinks(String siteName, String language, UserIdentity user) 503 { 504 return getUserLinks(siteName, language, user, null); 505 } 506 507 /** 508 * Get links of a given site and language, for the given user 509 * @param siteName the site name 510 * @param language the language 511 * @param user The user identity 512 * @param themeName the theme id to filter user links. If null, return all user links 513 * @return the links for the given user 514 */ 515 public AmetysObjectIterable<DefaultLink> getUserLinks(String siteName, String language, UserIdentity user, String themeName) 516 { 517 ThemeExpression themeExpression = null; 518 if (StringUtils.isNotBlank(themeName) && themeExists(themeName, siteName, language)) 519 { 520 themeExpression = new ThemeExpression(themeName); 521 } 522 523 String linksQuery = getUserLinksQuery(siteName, language, user, themeExpression); 524 return _ametysObjectResolver.query(linksQuery); 525 } 526 527 /** 528 * Checks if the links displayed in a link directory service has access restrictions 529 * @param siteName the name of the site 530 * @param language the language 531 * @param themesIds the list of selected theme ids 532 * @return true if the links of the service have access restrictions, false otherwise 533 */ 534 public boolean hasRestrictions(String siteName, String language, List<String> themesIds) 535 { 536 CacheKey cacheKey = CacheKey.of(siteName, language, themesIds); 537 return _getRestrictionsCache().get(cacheKey, __ -> { 538 // No themes => we check all the links' access restrictions 539 if (themesIds.isEmpty()) 540 { 541 String allLinksQuery = getAllLinksQuery(siteName, language); 542 try (AmetysObjectIterable<AmetysObject> links = _ametysObjectResolver.query(allLinksQuery)) 543 { 544 if (isAccessRestricted(links)) 545 { 546 return true; 547 } 548 } 549 550 551 } 552 // The service has themes specified => we solely check the corresponding links' access restrictions 553 else 554 { 555 for (String themeId : themesIds) 556 { 557 String xPathQuery = getLinksQuery(siteName, language, new ThemeExpression(themeId)); 558 try (AmetysObjectIterable<AmetysObject> links = _ametysObjectResolver.query(xPathQuery)) 559 { 560 if (isAccessRestricted(links)) 561 { 562 return true; 563 } 564 } 565 } 566 } 567 568 // All the tested links have no restricted access 569 return false; 570 }); 571 } 572 573 /** 574 * Checks if the links displayed in a link directory service has internal link 575 * @param siteName the name of the site 576 * @param language the language 577 * @param themesIds the list of selected theme ids 578 * @return true if the links of the service has internal link, false otherwise 579 */ 580 public boolean hasInternalUrl(String siteName, String language, List<String> themesIds) 581 { 582 CacheKey cacheKey = CacheKey.of(siteName, language, themesIds); 583 return _getInternalUrlCache().get(cacheKey, __ -> { 584 Site site = _siteManager.getSite(siteName); 585 String allowedIdParameter = site.getValue("allowed-ip"); 586 if (StringUtils.isBlank(allowedIdParameter)) 587 { 588 return false; 589 } 590 591 List<DefaultLink> links = getLinks(themesIds, siteName, language); 592 for (DefaultLink link : links) 593 { 594 if (StringUtils.isNotBlank(link.getInternalUrl())) 595 { 596 return true; 597 } 598 } 599 600 return false; 601 }); 602 } 603 604 /** 605 * Check if the links' access is restricted or not 606 * @param links the links to be tested 607 * @return true if the link has a restricted access, false otherwise 608 */ 609 public boolean isAccessRestricted(AmetysObjectIterable<AmetysObject> links) 610 { 611 Iterator<AmetysObject> it = links.iterator(); 612 613 while (it.hasNext()) 614 { 615 DefaultLink link = (DefaultLink) it.next(); 616 617 // If any of the links has a limited access, the service declares itself non-cacheable 618 if (!_rightManager.hasAnonymousReadAccess(link)) 619 { 620 return true; 621 } 622 } 623 624 return false; 625 } 626 627 private ModifiableTraversableAmetysObject getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException 628 { 629 ModifiableTraversableAmetysObject node; 630 if (parentNode.hasChild(nodeName)) 631 { 632 node = parentNode.getChild(nodeName); 633 } 634 else 635 { 636 node = parentNode.createChild(nodeName, nodeType); 637 parentNode.saveChanges(); 638 } 639 return node; 640 } 641 642 /** 643 * Get the configuration of links brought by skin 644 * @param skinName the skin name 645 * @return the skin configuration 646 * @throws IOException if an error occured 647 * @throws ConfigurationException if an error occured 648 * @throws SAXException if an error occured 649 */ 650 public Configuration getSkinLinksConfiguration(String skinName) throws IOException, ConfigurationException, SAXException 651 { 652 return getSkinLinksConfiguration(skinName, DEFAULT_CONF_FILE_PATH); 653 } 654 655 /** 656 * Get the configuration of links brought by skin 657 * @param skinName the skin name 658 * @param confFilePath The configuration file for links 659 * @return the skin configuration 660 * @throws IOException if an error occured 661 * @throws ConfigurationException if an error occured 662 * @throws SAXException if an error occured 663 */ 664 public Configuration getSkinLinksConfiguration(String skinName, String confFilePath) throws IOException, ConfigurationException, SAXException 665 { 666 Skin skin = _getSkinManager().getSkin(skinName); 667 668 // FIXME Merge helper does not support non existing file in parent skin 669 // => until fix merge of configuration is only supported for the default configuration file 670 if (DEFAULT_CONF_FILE_PATH.equals(confFilePath)) 671 { 672 try (InputStream xslIs = getClass().getResourceAsStream("link-directory-merge.xsl")) 673 { 674 return _getSkinConfigurationHelper().getInheritanceMergedConfiguration(skin, StringUtils.substringAfter(confFilePath, "skin://"), xslIs); 675 } 676 } 677 else 678 { 679 // return the configuration without merging, as file does not exist in parent skin(s) (currently not supported by merge algorithm) 680 Source source = null; 681 try 682 { 683 source = _sourceResolver.resolveURI(confFilePath); 684 if (source.exists()) 685 { 686 return new DefaultConfigurationBuilder().build(source.getInputStream()); 687 } 688 else 689 { 690 throw new ConfigurationException("There is no configuration file at path '" + confFilePath + "' (no input data for link directory)."); 691 } 692 } 693 finally 694 { 695 if (_sourceResolver != null && source != null) 696 { 697 _sourceResolver.release(source); 698 } 699 } 700 } 701 } 702 703 /** 704 * Sax the directory links 705 * @param siteName the site name 706 * @param contentHandler the content handler 707 * @param links the list of links to sax (can be null) 708 * @param restrictedThemes If not empty, only link's themes among this restricted list will be saxed 709 * @param userLinks the user links to sax (can be null) 710 * @param storageContext the storage context, null if there is no connected user 711 * @param isConfigurable true if links are configurable 712 * @param contextVars the context variables 713 * @param user the user 714 * @throws SAXException If an error occurs while generating the SAX events 715 * @throws UserPreferencesException if an exception occurs while getting the user preferences 716 */ 717 public void saxLinks(String siteName, ContentHandler contentHandler, List<DefaultLink> links, List<DefaultLink> userLinks, List<String> restrictedThemes, boolean isConfigurable, Map<String, String> contextVars, String storageContext, UserIdentity user) throws SAXException, UserPreferencesException 718 { 719 // left : true if user link 720 // right : the link itself 721 List<Pair<Boolean, DefaultLink>> allLinks = new ArrayList<>(); 722 723 if (links != null) 724 { 725 for (DefaultLink link : links) 726 { 727 allLinks.add(new ImmutablePair<>(false, link)); 728 } 729 } 730 731 if (userLinks != null) 732 { 733 for (DefaultLink link : userLinks) 734 { 735 allLinks.add(new ImmutablePair<>(true, link)); 736 } 737 } 738 739 740 String[] orderedLinksPrefLinksIdsArray = null; 741 String[] hiddenLinksPrefLinksIdsArray = null; 742 if (user != null && isConfigurable) 743 { 744 // TODO it would be nice to change the name of this user pref but the storage is still the same, so for the moment we avoid the SQL migration 745 // Cf issue LINKS-141 746 // Change in org.ametys.plugins.linkdirectory.LinkDirectorySetUserPreferencesAction#act too 747 748 Map<String, String> unTypedUserPrefs = _userPreferencesManager.getUnTypedUserPrefs(user, storageContext, contextVars); 749 750 String orderedLinksPrefValues = unTypedUserPrefs.get(USER_PREF_ORDERED_LINK_ATTR); 751 orderedLinksPrefLinksIdsArray = StringUtils.split(orderedLinksPrefValues, ","); 752 753 String hiddenLinksPrefValues = unTypedUserPrefs.get(USER_PREF_HIDDEN_LINK_ATTR); 754 hiddenLinksPrefLinksIdsArray = StringUtils.split(hiddenLinksPrefValues, ","); 755 } 756 757 Site site = _siteManager.getSite(siteName); 758 759 boolean hasIPRestriction = hasIPRestriction(site); 760 boolean isIPAuthorized = isInternalIP(site); 761 762 // Sort the list according to the orderedLinksPrefLinksIdsArray 763 if (ArrayUtils.isNotEmpty(orderedLinksPrefLinksIdsArray)) 764 { 765 DefaultLinkSorter defaultLinkSorter = new DefaultLinkSorter(allLinks, orderedLinksPrefLinksIdsArray); 766 allLinks.sort(defaultLinkSorter); 767 } 768 769 for (Pair<Boolean, DefaultLink> linkPair : allLinks) 770 { 771 DefaultLink link = linkPair.getRight(); 772 boolean userLink = linkPair.getLeft(); 773 774 LinkVisibility defaultVisibility = link.getDefaultVisibility(); 775 776 // check the access granted if it is not a user link 777 if (userLink || _isCurrentUserGrantedAccess(link)) 778 { 779 boolean selected = isConfigurable && ArrayUtils.contains(orderedLinksPrefLinksIdsArray, link.getId()); // deprecated, only used for old views, isHidden should be used now 780 boolean isHidden = isConfigurable && (ArrayUtils.contains(hiddenLinksPrefLinksIdsArray, link.getId()) || LinkVisibility.HIDDEN.equals(defaultVisibility) && !selected); 781 saxLink(siteName, contentHandler, link, restrictedThemes, selected, hasIPRestriction, isIPAuthorized, userLink, isHidden); 782 } 783 } 784 } 785 786 /** 787 * SAX a directory link. 788 * @param siteName the site name 789 * @param contentHandler the content handler 790 * @param link the link to sax. 791 * @param restrictedThemes If not empty, only link's themes among this restricted list of themes will be saxed 792 * @param selected true if a front end user has checked this link as a user preference, false otherwise (deprecated, only used for old views, isHidden should be used now) 793 * @param hasIPRestriction true if we have IP restriction 794 * @param isIPAuthorized true if the IP is authorized 795 * @param userLink true if it is a user link 796 * @param isHidden true if the link is hidden 797 * @throws SAXException If an error occurs while generating the SAX events 798 */ 799 public void saxLink (String siteName, ContentHandler contentHandler, DefaultLink link, List<String> restrictedThemes, boolean selected, boolean hasIPRestriction, boolean isIPAuthorized, boolean userLink, boolean isHidden) throws SAXException 800 { 801 AttributesImpl attrs = new AttributesImpl(); 802 attrs.addCDATAAttribute("id", link.getId()); 803 attrs.addCDATAAttribute("lang", link.getLanguage()); 804 805 LinkType urlType = link.getUrlType(); 806 807 _addURLAttribute(link, hasIPRestriction, isIPAuthorized, attrs); 808 809 attrs.addCDATAAttribute("urlType", StringUtils.defaultString(urlType.toString())); 810 811 if (link.getStatus() != LinkStatus.BROKEN) 812 { 813 String dynInfoProviderId = StringUtils.defaultString(link.getDynamicInformationProvider()); 814 // Check if provider exists 815 if (StringUtils.isNotEmpty(dynInfoProviderId) && _dynamicProviderEP.hasExtension(dynInfoProviderId)) 816 { 817 attrs.addCDATAAttribute("dynamicInformationProvider", dynInfoProviderId); 818 } 819 } 820 attrs.addCDATAAttribute("title", StringUtils.defaultString(link.getTitle())); 821 attrs.addCDATAAttribute("content", StringUtils.defaultString(link.getContent())); 822 823 if (urlType == LinkType.PAGE) 824 { 825 String pageId = link.getUrl(); 826 try 827 { 828 Page page = _ametysObjectResolver.resolveById(pageId); 829 attrs.addCDATAAttribute("pageTitle", page.getTitle()); 830 } 831 catch (UnknownAmetysObjectException e) 832 { 833 attrs.addCDATAAttribute("unknownPage", "true"); 834 } 835 } 836 837 attrs.addCDATAAttribute("alternative", StringUtils.defaultString(link.getAlternative())); 838 attrs.addCDATAAttribute("pictureAlternative", StringUtils.defaultString(link.getPictureAlternative())); 839 840 attrs.addCDATAAttribute("user-selected", selected ? "true" : "false"); 841 842 attrs.addCDATAAttribute("color", _linkDAO.getLinkColor(link)); 843 844 String pictureType = link.getPictureType(); 845 attrs.addCDATAAttribute("pictureType", pictureType); 846 if (pictureType.equals("resource")) 847 { 848 String resourceId = link.getResourcePictureId(); 849 try 850 { 851 Resource resource = _ametysObjectResolver.resolveById(resourceId); 852 attrs.addCDATAAttribute("pictureId", resourceId); 853 attrs.addCDATAAttribute("pictureName", resource.getName()); 854 attrs.addCDATAAttribute("pictureSize", Long.toString(resource.getLength())); 855 attrs.addCDATAAttribute("imageType", "explorer"); 856 } 857 catch (UnknownAmetysObjectException e) 858 { 859 getLogger().error("The resource of id'{}' does not exist anymore. The picture for link of id '{}' will be ignored.", resourceId, link.getId(), e); 860 } 861 862 } 863 else if (pictureType.equals("external")) 864 { 865 Binary picMeta = link.getExternalPicture(); 866 attrs.addCDATAAttribute("picturePath", DefaultLink.PROPERTY_PICTURE); 867 attrs.addCDATAAttribute("pictureName", picMeta.getFilename()); 868 attrs.addCDATAAttribute("pictureSize", Long.toString(picMeta.getLength())); 869 attrs.addCDATAAttribute("imageType", "link-data"); 870 } 871 else if (pictureType.equals("glyph")) 872 { 873 attrs.addCDATAAttribute("pictureGlyph", link.getPictureGlyph()); 874 } 875 876 attrs.addCDATAAttribute("limitedAccess", String.valueOf(!_rightManager.hasAnonymousReadAccess(link))); 877 878 attrs.addCDATAAttribute("userLink", String.valueOf(userLink)); 879 attrs.addCDATAAttribute("isHidden", String.valueOf(isHidden)); 880 881 LinkStatus status = link.getStatus(); 882 if (status != null) 883 { 884 attrs.addCDATAAttribute("status", status.name()); 885 } 886 887 if (StringUtils.isNotBlank(link.getPage())) 888 { 889 attrs.addCDATAAttribute("page", link.getPage()); 890 } 891 892 XMLUtils.startElement(contentHandler, "link", attrs); 893 894 // Themes 895 _saxThemes(contentHandler, link, restrictedThemes); 896 897 XMLUtils.endElement(contentHandler, "link"); 898 } 899 900 /** 901 * Add the URL attribute to sax 902 * @param link the link 903 * @param hasIPRestriction true if we have IP restriction 904 * @param isIPAuthorized true if the IP is authorized 905 * @param attrs the attribute 906 */ 907 private void _addURLAttribute(DefaultLink link, boolean hasIPRestriction, boolean isIPAuthorized, AttributesImpl attrs) 908 { 909 String internalUrl = link.getInternalUrl(); 910 String externalUrl = link.getUrl(); 911 912 // If we have no internal URL or no IP restriction, just sax external URL 913 if (StringUtils.isBlank(internalUrl) || !hasIPRestriction) 914 { 915 attrs.addCDATAAttribute("url", StringUtils.defaultString(externalUrl)); 916 } 917 else 918 { 919 // If the IP is authorized, sax internal URL 920 if (isIPAuthorized) 921 { 922 attrs.addCDATAAttribute("url", StringUtils.defaultString(internalUrl)); 923 } 924 // else if we have external URL, we sax it 925 else if (StringUtils.isNotBlank(externalUrl)) 926 { 927 attrs.addCDATAAttribute("url", StringUtils.defaultString(externalUrl)); 928 } 929 // else link is disabled it because the IP is not authorized 930 else 931 { 932 attrs.addCDATAAttribute("disabled", "true"); 933 } 934 } 935 } 936 937 /** 938 * Get the actual ids of the themes configured properly, their names if they were not 939 * @param configuredThemesNames the normalized ids of the configured themes 940 * @param siteName the site's name 941 * @param language the site's language 942 * @return the actual ids of the configured themes 943 */ 944 public Map<String, List<String>> getThemesMap(List<String> configuredThemesNames, String siteName, String language) 945 { 946 Map<String, List<String>> themesMap = new HashMap<> (); 947 List<String> correctThemesList = new ArrayList<> (); 948 List<String> wrongThemesList = new ArrayList<> (); 949 950 for (int i = 0; i < configuredThemesNames.size(); i++) 951 { 952 String configuredThemeName = configuredThemesNames.get(i); 953 954 Map<String, Object> contextualParameters = new HashMap<>(); 955 contextualParameters.put("language", language); 956 contextualParameters.put("siteName", siteName); 957 Tag theme = _themesDAO.getTag(configuredThemeName, contextualParameters); 958 959 if (theme == null) 960 { 961 getLogger().warn("The theme '{}' was not found. It will be ignored.", configuredThemeName); 962 wrongThemesList.add(configuredThemeName); 963 } 964 else 965 { 966 correctThemesList.add(configuredThemeName); 967 } 968 } 969 970 themesMap.put("themes", correctThemesList); 971 themesMap.put("unknown-themes", wrongThemesList); 972 return themesMap; 973 } 974 975 /** 976 * Verify the existence of a theme 977 * @param themeName the id of the theme to verify 978 * @param siteName the site's name 979 * @param language the site's language 980 * @return true if the theme exists, false otherwise 981 */ 982 public boolean themeExists(String themeName, String siteName, String language) 983 { 984 if (StringUtils.isBlank(themeName)) 985 { 986 return false; 987 } 988 Map<String, Object> contextualParameters = new HashMap<>(); 989 contextualParameters.put("language", language); 990 contextualParameters.put("siteName", siteName); 991 List<String> checkTags = _themesDAO.checkTags(List.of(themeName), false, Collections.EMPTY_MAP, contextualParameters); 992 return !checkTags.isEmpty(); 993 } 994 995 /** 996 * Get theme's title from its name 997 * @param themeName the theme name 998 * @param siteName the site's name 999 * @param language the site's language 1000 * @return the title of the theme. Null if the theme doesn't exist 1001 */ 1002 public I18nizableText getThemeTitle(String themeName, String siteName, String language) 1003 { 1004 Map<String, Object> contextualParameters = new HashMap<>(); 1005 contextualParameters.put("language", language); 1006 contextualParameters.put("siteName", siteName); 1007 if (themeExists(themeName, siteName, language)) 1008 { 1009 Tag tag = _themesDAO.getTag(themeName, contextualParameters); 1010 return tag.getTitle(); 1011 } 1012 else 1013 { 1014 getLogger().warn("Can't find theme with name {} for site {} and language {}", themeName, siteName, language); 1015 } 1016 1017 return null; 1018 } 1019 1020 /** 1021 * Get the site's name 1022 * @param request the request 1023 * @return the site's name 1024 */ 1025 public String getSiteName(Request request) 1026 { 1027 return WebHelper.getSiteName(request, (Page) request.getAttribute(Page.class.getName())); 1028 } 1029 1030 /** 1031 * Get the site's language 1032 * @param request the request 1033 * @return the site's language 1034 */ 1035 public String getLanguage(Request request) 1036 { 1037 Page page = (Page) request.getAttribute(Page.class.getName()); 1038 if (page != null) 1039 { 1040 return page.getSitemapName(); 1041 } 1042 1043 String language = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME); 1044 if (StringUtils.isEmpty(language)) 1045 { 1046 language = request.getParameter("language"); 1047 } 1048 1049 return language; 1050 } 1051 1052 /** 1053 * Retrieve the context variables from the front 1054 * @param request the request 1055 * @return the map of context variables 1056 */ 1057 public Map<String, String> getContextVars(Request request) 1058 { 1059 Map<String, String> contextVars = new HashMap<> (); 1060 1061 contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_SITENAME, getSiteName(request)); 1062 contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_LANGUAGE, getLanguage(request)); 1063 1064 return contextVars; 1065 } 1066 1067 /** 1068 * Get the appropriate storage context from request 1069 * @param request the request 1070 * @param zoneItemId the id of the zone item if we deal with a service, null for an input data 1071 * @return the storage context in which the user preferences will be kept 1072 */ 1073 public String getStorageContext(Request request, String zoneItemId) 1074 { 1075 String siteName = getSiteName(request); 1076 String language = getLanguage(request); 1077 1078 return StringUtils.isEmpty(zoneItemId) ? siteName + "/" + language : siteName + "/" + language + "/" + zoneItemId; 1079 } 1080 1081 /** 1082 * Get the appropriate storage context 1083 * @param siteName the name of the site 1084 * @param language the language 1085 * @param zoneItemId the id of the zone item if we deal with a service, null for an input data 1086 * @return the storage context in which the user preferences will be kept 1087 */ 1088 public String getStorageContext(String siteName, String language, String zoneItemId) 1089 { 1090 return StringUtils.isEmpty(zoneItemId) ? siteName + "/" + language : siteName + "/" + language + "/" + zoneItemId; 1091 } 1092 1093 /** 1094 * Sax the themes 1095 * @param contentHandler the content handler 1096 * @param link the link 1097 * @param restrictedThemes If not empty, only link's themes among this restricted list will be saxed 1098 * @throws SAXException If an error occurs while generating the SAX events 1099 */ 1100 private void _saxThemes (ContentHandler contentHandler, DefaultLink link, List<String> restrictedThemes) throws SAXException 1101 { 1102 XMLUtils.startElement(contentHandler, "themes"); 1103 1104 Map<String, Object> contextualParameters = new HashMap<>(); 1105 contextualParameters.put("language", link.getLanguage()); 1106 contextualParameters.put("siteName", link.getSiteName()); 1107 1108 for (String themeId : link.getThemes()) 1109 { 1110 try 1111 { 1112 Tag tag = _themesDAO.getTag(themeId, contextualParameters); 1113 if (tag != null) 1114 { 1115 if (restrictedThemes.isEmpty() || restrictedThemes.contains(themeId)) 1116 { 1117 AttributesImpl attrs = new AttributesImpl(); 1118 attrs.addCDATAAttribute("id", themeId); 1119 attrs.addCDATAAttribute("name", tag.getName()); 1120 1121 XMLUtils.startElement(contentHandler, "theme", attrs); 1122 tag.getTitle().toSAX(contentHandler, "label"); 1123 XMLUtils.endElement(contentHandler, "theme"); 1124 } 1125 } 1126 else 1127 { 1128 getLogger().error("Theme '{}' in link '{}' can not be found.", themeId, link.getId()); 1129 } 1130 } 1131 catch (UnknownAmetysObjectException e) 1132 { 1133 // Theme does not exist anymore 1134 } 1135 } 1136 1137 1138 XMLUtils.endElement(contentHandler, "themes"); 1139 } 1140 1141 /** 1142 * Determines if the current user is allowed to see the link or not 1143 * @param link the link 1144 * @return true if the current user is allowed to see the link, false otherwise 1145 */ 1146 private boolean _isCurrentUserGrantedAccess(DefaultLink link) 1147 { 1148 UserIdentity user = _currentUserProvider.getUser(); 1149 1150 // There is no access restriction 1151 return _rightManager.hasReadAccess(user, link); 1152 } 1153 1154 /** 1155 * Determines if the site has IP restriction for internal links 1156 * @param site the site 1157 * @return true if the site has IP restriction 1158 */ 1159 public boolean hasIPRestriction(Site site) 1160 { 1161 return site.getValue("allowed-ip") != null; 1162 } 1163 1164 /** 1165 * Determines if the user IP matches the configured internal IP range 1166 * @param site the site 1167 * @return true if the user IP is an authorized IP for internal links or if no IP restriction is configured 1168 */ 1169 public boolean isInternalIP(Site site) 1170 { 1171 String ipRegexp = site.getValue("allowed-ip"); 1172 if (StringUtils.isNotBlank(ipRegexp)) 1173 { 1174 Pattern ipRestriction = Pattern.compile(ipRegexp); 1175 1176 Request request = ContextHelper.getRequest(_context); 1177 1178 // The real client IP may have been put in the non-standard "X-Forwarded-For" request header, in case of reverse proxy 1179 String xff = request.getHeader("X-Forwarded-For"); 1180 String ip = null; 1181 1182 if (xff != null) 1183 { 1184 ip = xff.split(",")[0]; 1185 } 1186 else 1187 { 1188 ip = request.getRemoteAddr(); 1189 } 1190 1191 boolean internalIP = ipRestriction.matcher(ip).matches(); 1192 1193 if (getLogger().isDebugEnabled()) 1194 { 1195 getLogger().debug("Ip '{}' is considered {} with pattern {}", ip, internalIP ? "internal" : "external", ipRestriction.pattern()); 1196 } 1197 1198 return internalIP; 1199 } 1200 1201 // There is no IP restriction, considered user IP is authorized 1202 return true; 1203 } 1204 1205 /** 1206 * Helper class to sort links (DefaultLinkSorter implementation) 1207 * If both links are in the ordered links list, this order is used 1208 * If one of them is in it and not the other, the one in it will be before the other 1209 * If none of them is in the list, the initial order will be used 1210 */ 1211 private class DefaultLinkSorter implements Comparator<Pair<Boolean, DefaultLink>> 1212 { 1213 private String[] _orderedLinksPrefLinksIdsArray; 1214 private List<String> _initialList; 1215 /** 1216 * constructor for the helper 1217 * @param initialList initial list to keep track of the original order if no order is found 1218 * @param orderedLinksPrefLinksIdsArray ordered list of link ids 1219 */ 1220 public DefaultLinkSorter(List<Pair<Boolean, DefaultLink>> initialList, String[] orderedLinksPrefLinksIdsArray) 1221 { 1222 _orderedLinksPrefLinksIdsArray = orderedLinksPrefLinksIdsArray; 1223 _initialList = initialList.stream() 1224 .map(Pair::getRight) 1225 .map(DefaultLink::getId) 1226 .collect(Collectors.toList()); 1227 } 1228 public int compare(Pair<Boolean, DefaultLink> pair1, Pair<Boolean, DefaultLink> pair2) 1229 { 1230 DefaultLink link1 = pair1.getRight(); 1231 DefaultLink link2 = pair2.getRight(); 1232 if (ArrayUtils.isNotEmpty(_orderedLinksPrefLinksIdsArray)) 1233 { 1234 int nbOrderedLinks = _orderedLinksPrefLinksIdsArray.length; 1235 int pos1 = ArrayUtils.indexOf(_orderedLinksPrefLinksIdsArray, link1.getId()); 1236 if (pos1 == ArrayUtils.INDEX_NOT_FOUND) 1237 { 1238 pos1 = nbOrderedLinks + _initialList.indexOf(link1.getId()); // if not found, keep orginal order after user's order 1239 } 1240 1241 int pos2 = ArrayUtils.indexOf(_orderedLinksPrefLinksIdsArray, link2.getId()); 1242 if (pos2 == ArrayUtils.INDEX_NOT_FOUND) 1243 { 1244 pos2 = nbOrderedLinks + _initialList.indexOf(link1.getId()); // if not found, keep orginal order after user's order 1245 } 1246 1247 return pos1 - pos2; 1248 } 1249 else 1250 { 1251 return 0; // No sorting if no sort array 1252 } 1253 } 1254 } 1255 1256 private Cache<CacheKey, Boolean> _getRestrictionsCache() 1257 { 1258 return _cacheManager.get(__RESTRICTIONS_CACHE); 1259 } 1260 1261 private Cache<CacheKey, Boolean> _getInternalUrlCache() 1262 { 1263 return _cacheManager.get(__INTERNAL_URL_CACHE); 1264 } 1265 1266 static final class CacheKey extends AbstractCacheKey 1267 { 1268 private CacheKey(String siteName, String language, List<String> themeIds) 1269 { 1270 super(siteName, language, themeIds); 1271 } 1272 1273 static CacheKey of(String siteName, String language, List<String> themeIds) 1274 { 1275 return new CacheKey(siteName, language, themeIds); 1276 } 1277 } 1278}