Modified: incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/auth/user/XMLUserDatabase.java URL: http://svn.apache.org/viewvc/incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/auth/user/XMLUserDatabase.java?rev=682144&r1=682143&r2=682144&view=diff ============================================================================== --- incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/auth/user/XMLUserDatabase.java (original) +++ incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/auth/user/XMLUserDatabase.java Sun Aug 3 05:17:34 2008 @@ -1,21 +1,22 @@ /* - JSPWiki - a JSP-based WikiWiki clone. + JSPWiki - a JSP-based WikiWiki clone. - Copyright (C) 2001-2005 Janne Jalkanen ([EMAIL PROTECTED]) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU Lesser General Public License as published by - the Free Software Foundation; either version 2.1 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License - along with this program; if not, write to the Free Software - Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. */ package com.ecyrd.jspwiki.auth.user; @@ -24,17 +25,12 @@ import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.HashSet; -import java.util.Properties; -import java.util.Set; +import java.util.*; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; +import org.w3c.dom.*; import org.xml.sax.SAXException; import com.ecyrd.jspwiki.NoRequiredPropertyException; @@ -42,6 +38,7 @@ import com.ecyrd.jspwiki.auth.NoSuchPrincipalException; import com.ecyrd.jspwiki.auth.WikiPrincipal; import com.ecyrd.jspwiki.auth.WikiSecurityException; +import com.ecyrd.jspwiki.util.Serializer; /** * <p>Manages [EMAIL PROTECTED] DefaultUserProfile} objects using XML files for persistence. @@ -73,6 +70,8 @@ private static final String DEFAULT_USERDATABASE = "userdatabase.xml"; + private static final String ATTRIBUTES_TAG = "attributes"; + private static final String CREATED = "created"; private static final String EMAIL = "email"; @@ -83,8 +82,12 @@ private static final String LAST_MODIFIED = "lastModified"; + private static final String LOCK_EXPIRY = "lockExpiry"; + private static final String PASSWORD = "password"; + private static final String UID = "uid"; + private static final String USER_TAG = "user"; private static final String WIKI_NAME = "wikiName"; @@ -185,6 +188,19 @@ } /** + * [EMAIL PROTECTED] + */ + public UserProfile findByUid( long uid ) throws NoSuchPrincipalException + { + UserProfile profile = findByAttribute( UID, Long.toString( uid ) ); + if ( profile != null ) + { + return profile; + } + throw new NoSuchPrincipalException( "Not in database: " + uid ); + } + + /** * Looks up and returns the first [EMAIL PROTECTED] UserProfile}in the user database * that matches a profile having a given wiki name. If the user database * does not contain a user with a matching attribute, throws a @@ -209,6 +225,7 @@ * contain any profiles, this method will return a zero-length * array. * @return the WikiNames + * @throws WikiSecurityException In case things fail. */ public Principal[] getWikiNames() throws WikiSecurityException { @@ -216,7 +233,7 @@ { throw new IllegalStateException( "FATAL: database does not exist" ); } - Set principals = new HashSet(); + SortedSet<Principal> principals = new TreeSet<Principal>(); NodeList users = c_dom.getElementsByTagName( USER_TAG ); for( int i = 0; i < users.getLength(); i++ ) { @@ -232,7 +249,7 @@ principals.add( principal ); } } - return (Principal[])principals.toArray( new Principal[principals.size()] ); + return principals.toArray( new Principal[principals.size()] ); } /** @@ -345,7 +362,9 @@ for( int i = 0; i < nodes.getLength(); i++ ) { Element user = (Element)nodes.item( i ); - io.write( "<" + USER_TAG + " "); + io.write( " <" + USER_TAG + " "); + io.write( UID ); + io.write( "=\"" + user.getAttribute( UID ) + "\" " ); io.write( LOGIN_NAME ); io.write( "=\"" + user.getAttribute( LOGIN_NAME ) + "\" " ); io.write( WIKI_NAME ); @@ -360,7 +379,19 @@ io.write( "=\"" + user.getAttribute( CREATED ) + "\" " ); io.write( LAST_MODIFIED ); io.write( "=\"" + user.getAttribute( LAST_MODIFIED ) + "\" " ); - io.write(" />\n"); + io.write( LOCK_EXPIRY ); + io.write( "=\"" + user.getAttribute( LOCK_EXPIRY ) + "\" " ); + io.write( ">" ); + NodeList attributes = user.getElementsByTagName( ATTRIBUTES_TAG ); + for ( int j = 0; j < attributes.getLength(); j++ ) + { + Element attribute = (Element)attributes.item( j ); + String value = extractText( attribute ); + io.write( "\n <" + ATTRIBUTES_TAG + ">" ); + io.write( value ); + io.write( "</" + ATTRIBUTES_TAG + ">" ); + } + io.write("\n </" +USER_TAG + ">\n"); } io.write("</users>"); io.close(); @@ -411,16 +442,6 @@ } } } - - /** - * Determines whether the user database shares user/password data with the - * web container; always returns <code>false</code>. - * @see com.ecyrd.jspwiki.auth.user.UserDatabase#isSharedWithContainer() - */ - public boolean isSharedWithContainer() - { - return false; - } /** * @see com.ecyrd.jspwiki.auth.user.UserDatabase#rename(String, String) @@ -492,31 +513,46 @@ String index = profile.getLoginName(); NodeList users = c_dom.getElementsByTagName( USER_TAG ); Element user = null; - boolean isNew = true; for( int i = 0; i < users.getLength(); i++ ) { Element currentUser = (Element) users.item( i ); if ( currentUser.getAttribute( LOGIN_NAME ).equals( index ) ) { user = currentUser; - isNew = false; break; } } + + boolean isNew = false; + Date modDate = new Date( System.currentTimeMillis() ); - if ( isNew ) + if( user == null ) { + // Create new user node profile.setCreated( modDate ); log.info( "Creating new user " + index ); user = c_dom.createElement( USER_TAG ); c_dom.getDocumentElement().appendChild( user ); setAttribute( user, CREATED, c_format.format( profile.getCreated() ) ); + isNew = true; + } + else + { + // To update existing user node, delete old attributes first... + NodeList attributes = user.getElementsByTagName( ATTRIBUTES_TAG ); + for ( int i = 0; i < attributes.getLength(); i++ ) + { + user.removeChild( attributes.item( i ) ); + } } + setAttribute( user, LAST_MODIFIED, c_format.format( modDate ) ); setAttribute( user, LOGIN_NAME, profile.getLoginName() ); setAttribute( user, FULL_NAME, profile.getFullname() ); setAttribute( user, WIKI_NAME, profile.getWikiName() ); setAttribute( user, EMAIL, profile.getEmail() ); + Date lockExpiry = profile.getLockExpiry(); + setAttribute( user, LOCK_EXPIRY, lockExpiry == null ? "" : c_format.format( lockExpiry ) ); // Hash and save the new password if it's different from old one String newPassword = profile.getPassword(); @@ -525,7 +561,24 @@ String oldPassword = user.getAttribute( PASSWORD ); if ( !oldPassword.equals( newPassword ) ) { - setAttribute( user, PASSWORD, SHA_PREFIX + getHash( newPassword ) ); + setAttribute( user, PASSWORD, getHash( newPassword ) ); + } + } + + // Save the attributes as as Base64 string + if ( profile.getAttributes().size() > 0 ) + { + try + { + String encodedAttributes = Serializer.serializeToBase64( profile.getAttributes() ); + Element attributes = c_dom.createElement( ATTRIBUTES_TAG ); + user.appendChild( attributes ); + Text value = c_dom.createTextNode( encodedAttributes ); + attributes.appendChild( value ); + } + catch ( IOException e ) + { + throw new WikiSecurityException( "Could not save user profile attribute. Reason: " + e.getMessage() ); } } @@ -542,7 +595,8 @@ /** * Private method that returns the first [EMAIL PROTECTED] UserProfile}matching a - * <user> element's supplied attribute. + * <user> element's supplied attribute. This method will also + * set the UID if it has not yet been set. * @param matchAttribute * @param index * @return the profile, or <code>null</code> if not found @@ -562,16 +616,52 @@ Element user = (Element) users.item( i ); if ( user.getAttribute( matchAttribute ).equals( index ) ) { - UserProfile profile = new DefaultUserProfile(); + UserProfile profile = newProfile(); + + // Parse basic attributes + profile.setUid( parseLong( user.getAttribute( UID ) ) ); + if ( profile.getUid() == UID_NOT_SET ) + { + profile.setUid( generateUid( this ) ); + } profile.setLoginName( user.getAttribute( LOGIN_NAME ) ); profile.setFullname( user.getAttribute( FULL_NAME ) ); profile.setPassword( user.getAttribute( PASSWORD ) ); profile.setEmail( user.getAttribute( EMAIL ) ); + + // Get created/modified timestamps String created = user.getAttribute( CREATED ); String modified = user.getAttribute( LAST_MODIFIED ); - profile.setCreated( parseDate( profile, created ) ); profile.setLastModified( parseDate( profile, modified ) ); + + // Is the profile locked? + String lockExpiry = user.getAttribute( LOCK_EXPIRY ); + if ( lockExpiry == null || lockExpiry.length() == 0 ) + { + profile.setLockExpiry( null ); + } + else + { + profile.setLockExpiry( new Date( Long.parseLong( lockExpiry ) ) ); + } + + // Extract all of the user's attributes (should only be one attributes tag, but you never know!) + NodeList attributes = user.getElementsByTagName( ATTRIBUTES_TAG ); + for ( int j = 0; j < attributes.getLength(); j++ ) + { + Element attribute = (Element)attributes.item( j ); + String serializedMap = extractText( attribute ); + try + { + Map<String,? extends Serializable> map = Serializer.deserializeFromBase64( serializedMap ); + profile.getAttributes().putAll( map ); + } + catch ( IOException e ) + { + log.error( "Could not parse user profile attributes!", e ); + } + } return profile; } @@ -580,6 +670,29 @@ } /** + * Extracts all of the text nodes that are immediate children of an Element. + * @param element the base element + * @return the text nodes that are immediate children of the base element, concatenated together + */ + private String extractText( Element element ) + { + String text = ""; + if ( element.getChildNodes().getLength() > 0 ) + { + NodeList children = element.getChildNodes(); + for ( int k = 0; k < children.getLength(); k++ ) + { + Node child = children.item( k ); + if ( child.getNodeType() == Node.TEXT_NODE ) + { + text = text + ((Text)child).getData(); + } + } + } + return text; + } + + /** * Tries to parse a date using the default format - then, for backwards * compatibility reasons, tries the platform default. * @@ -625,6 +738,16 @@ for( int i = 0; i < users.getLength(); i++ ) { Element user = (Element) users.item( i ); + + // Sanitize UID (and generate a new one if one does not exist) + String uid = user.getAttribute( UID ).trim(); + if ( uid == null || uid.length() == 0 || "-1".equals( uid ) ) + { + uid = String.valueOf( generateUid( this ) ); + user.setAttribute( UID, uid ); + } + + // Sanitize dates String loginName = user.getAttribute( LOGIN_NAME ); String created = user.getAttribute( CREATED ); String modified = user.getAttribute( LAST_MODIFIED ); @@ -656,7 +779,7 @@ } /** - * Private method that sets an attibute value for a supplied DOM element. + * Private method that sets an attribute value for a supplied DOM element. * @param element the element whose attribute is to be set * @param attribute the name of the attribute to set * @param value the desired attribute value
Added: incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/PageRenamer.java URL: http://svn.apache.org/viewvc/incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/PageRenamer.java?rev=682144&view=auto ============================================================================== --- incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/PageRenamer.java (added) +++ incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/PageRenamer.java Sun Aug 3 05:17:34 2008 @@ -0,0 +1,374 @@ +/* + JSPWiki - a JSP-based WikiWiki clone. + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + */ +package com.ecyrd.jspwiki.content; + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.log4j.Logger; + +import com.ecyrd.jspwiki.*; +import com.ecyrd.jspwiki.attachment.Attachment; +import com.ecyrd.jspwiki.parser.JSPWikiMarkupParser; +import com.ecyrd.jspwiki.parser.MarkupParser; +import com.ecyrd.jspwiki.providers.ProviderException; + +/** + * Provides page renaming functionality. Note that there used to be + * a similarly named class in 2.6, but due to unclear copyright, the + * class was completely rewritten from scratch for 2.8. + * + * @since 2.8 + */ +public class PageRenamer +{ + + private static final Logger log = Logger.getLogger( PageRenamer.class ); + + private boolean m_camelCase = false; + + /** + * Renames a page. + * + * @param context The current context. + * @param renameFrom The name from which to rename. + * @param renameTo The new name. + * @param changeReferrers If true, also changes all the referrers. + * @return The final new name (in case it had to be modified) + * @throws WikiException If the page cannot be renamed. + */ + public String renamePage( WikiContext context, + String renameFrom, + String renameTo, + boolean changeReferrers ) + throws WikiException + { + // + // Sanity checks first + // + if( renameFrom == null || renameFrom.length() == 0 ) + { + throw new WikiException( "From name may not be null or empty" ); + } + if( renameTo == null || renameTo.length() == 0 ) + { + throw new WikiException( "To name may not be null or empty" ); + } + + // + // Clean up the "to" -name so that it does not contain anything illegal + // + + renameTo = MarkupParser.cleanLink( renameTo.trim() ); + + if( renameTo.equals(renameFrom) ) + { + throw new WikiException( "You cannot rename the page to itself" ); + } + + // + // Preconditions: "from" page must exist, and "to" page must not yet exist. + // + WikiEngine engine = context.getEngine(); + WikiPage fromPage = engine.getPage( renameFrom ); + + if( fromPage == null ) + { + throw new WikiException("No such page "+renameFrom); + } + + WikiPage toPage = engine.getPage( renameTo ); + + if( toPage != null ) + { + throw new WikiException("Page already exists "+renameTo); + } + + // + // Options + // + + m_camelCase = TextUtil.getBooleanProperty( engine.getWikiProperties(), + JSPWikiMarkupParser.PROP_CAMELCASELINKS, + m_camelCase ); + + // + // Do the actual rename by changing from the frompage to the topage, including + // all of the attachments + // + + engine.getPageManager().getProvider().movePage( renameFrom, renameTo ); + + if( engine.getAttachmentManager().attachmentsEnabled() ) + { + engine.getAttachmentManager().getCurrentProvider().moveAttachmentsForPage( renameFrom, renameTo ); + } + + // + // Add a comment to the page notifying what changed. This adds a new revision + // to the repo with no actual change. + // + + toPage = engine.getPage( renameTo ); + + if( toPage == null ) throw new InternalWikiException("Rename seems to have failed for some strange reason - please check logs!"); + + toPage.setAttribute( WikiPage.CHANGENOTE, "Renamed from "+fromPage.getName() ); + toPage.setAuthor( context.getCurrentUser().getName() ); + + engine.getPageManager().putPageText( toPage, engine.getPureText( toPage ) ); + + // + // Update the references + // + + engine.getReferenceManager().pageRemoved( fromPage ); + engine.updateReferences( toPage ); + + // + // Update referrers first + // + if( changeReferrers ) + { + updateReferrers( context, fromPage, toPage ); + } + + + // + // Done, return the new name. + // + return renameTo; + } + + /** + * This method finds all the pages which have anything to do with the fromPage and + * change any referrers it can figure out in that page. + * + * @param context WikiContext in which we operate + * @param fromPage The old page + * @param toPage The new page + */ + @SuppressWarnings("unchecked") + private void updateReferrers( WikiContext context, WikiPage fromPage, WikiPage toPage ) + { + WikiEngine engine = context.getEngine(); + Set<String> referrers = new TreeSet<String>(); + + Collection<String> r = engine.getReferenceManager().findReferrers( fromPage.getName() ); + if( r != null ) referrers.addAll( r ); + + try + { + Collection<Attachment> attachments = engine.getAttachmentManager().listAttachments( fromPage ); + + for( Attachment att : attachments ) + { + Collection<String> c = engine.getReferenceManager().findReferrers(att.getName()); + + if( c != null ) referrers.addAll(c); + } + } + catch( ProviderException e ) + { + // We will continue despite this error + log.error( "Provider error while fetching attachments for rename", e ); + } + + + if( referrers.isEmpty() ) return; // No referrers + + for( String pageName : referrers ) + { + WikiPage p = engine.getPage( pageName ); + + String sourceText = engine.getPureText( p ); + + String newText = replaceReferrerString( context, sourceText, fromPage.getName(), toPage.getName() ); + + if( m_camelCase ) + newText = replaceCCReferrerString( context, newText, fromPage.getName(), toPage.getName() ); + + if( !sourceText.equals( newText ) ) + { + p.setAttribute( WikiPage.CHANGENOTE, "Renaming change "+fromPage.getName()+" to "+toPage.getName() ); + p.setAuthor( context.getCurrentUser().getName() ); + + try + { + engine.getPageManager().putPageText( p, newText ); + engine.updateReferences( p ); + } + catch( ProviderException e ) + { + // + // We fail with an error, but we will try to continue to rename + // other referrers as well. + // + log.error("Unable to perform rename.",e); + } + } + } + } + + /** + * Replaces camelcase links. + */ + private String replaceCCReferrerString( WikiContext context, String sourceText, String from, String to ) + { + StringBuilder sb = new StringBuilder( sourceText.length()+32 ); + + Pattern linkPattern = Pattern.compile( "\\p{Lu}+\\p{Ll}+\\p{Lu}+[\\p{L}\\p{Digit}]*" ); + + Matcher matcher = linkPattern.matcher( sourceText ); + + int start = 0; + + while( matcher.find(start) ) + { + String match = matcher.group(); + + sb.append( sourceText.substring( start, matcher.start() ) ); + + int lastOpenBrace = sourceText.lastIndexOf( '[', matcher.start() ); + int lastCloseBrace = sourceText.lastIndexOf( ']', matcher.start() ); + + if( match.equals( from ) && lastCloseBrace >= lastOpenBrace ) + { + sb.append( to ); + } + else + { + sb.append( match ); + } + + start = matcher.end(); + } + + sb.append( sourceText.substring( start ) ); + + return sb.toString(); + } + + private String replaceReferrerString( WikiContext context, String sourceText, String from, String to ) + { + StringBuilder sb = new StringBuilder( sourceText.length()+32 ); + + Pattern linkPattern = Pattern.compile( "([\\[\\~]?)\\[([^\\|\\]]*)(\\|)?([^\\|\\]]*)(\\|)?([^\\|\\]]*)\\]" ); + + Matcher matcher = linkPattern.matcher( sourceText ); + + int start = 0; + + //System.out.println("===="); + //System.out.println("SRC="+sourceText.trim()); + while( matcher.find(start) ) + { + char charBefore = (char)-1; + + if( matcher.start() > 0 ) + charBefore = sourceText.charAt( matcher.start()-1 ); + + if( matcher.group(1).length() > 0 || charBefore == '~' || charBefore == '[' ) + { + // + // Found an escape character, so I am escaping. + // + sb.append( sourceText.substring( start, matcher.end() ) ); + start = matcher.end(); + continue; + } + + String text = matcher.group(2); + String link = matcher.group(4); + String attr = matcher.group(6); + + /* + System.out.println("MATCH="+matcher.group(0)); + System.out.println(" text="+text); + System.out.println(" link="+link); + System.out.println(" attr="+attr); + */ + if( link.length() == 0 ) + { + text = replaceSingleLink( context, text, from, to ); + } + else + { + link = replaceSingleLink( context, link, from, to ); + + // + // A very simple substitution, but should work for quite a few cases. + // + text = TextUtil.replaceString( text, from, to ); + } + + // + // Construct the new string + // + sb.append( sourceText.substring( start, matcher.start() ) ); + sb.append( "["+text ); + if( link.length() > 0 ) sb.append( "|" + link ); + if( attr.length() > 0 ) sb.append( "|" + attr ); + sb.append( "]" ); + + start = matcher.end(); + } + + sb.append( sourceText.substring( start ) ); + + return sb.toString(); + } + + /** + * This method does a correct replacement of a single link, taking into + * account anchors and attachments. + */ + private String replaceSingleLink( WikiContext context, String original, String from, String newlink ) + { + int hash = original.indexOf( '#' ); + int slash = original.indexOf( '/' ); + String reallink = original; + + if( hash != -1 ) reallink = original.substring( 0, hash ); + if( slash != -1 ) reallink = original.substring( 0,slash ); + + reallink = MarkupParser.cleanLink( reallink ); + + // WikiPage p = context.getEngine().getPage( reallink ); + // WikiPage p2 = context.getEngine().getPage( from ); + + // System.out.println(" "+reallink+" :: "+ from); + // System.out.println(" "+p+" :: "+p2); + + // + // Yes, these point to the same page. + // + if( reallink.equals(from) ) + { + return newlink + ((hash > 0) ? original.substring( hash ) : "") + ((slash > 0) ? original.substring( slash ) : "") ; + } + + return original; + } +} Added: incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/package.html URL: http://svn.apache.org/viewvc/incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/package.html?rev=682144&view=auto ============================================================================== --- incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/package.html (added) +++ incubator/jspwiki/branches/JSPWIKI_2_9_STRIPES_BRANCH/src/com/ecyrd/jspwiki/content/package.html Sun Aug 3 05:17:34 2008 @@ -0,0 +1,13 @@ +<body> +Provides content management functionality for JSPWiki. + +<h2>Package specification</h2> + +<p>This package will in 3.0 contain all content management functionality. Currently +it is a bit in-the-making.</p> + +<h2>Related documentation</h2> + +TBD. + +</body> \ No newline at end of file
