From 4bdc74928f12f26ea058b1fd304affe944717aeb Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 16:12:16 -0400 Subject: [PATCH] Bring #209 and #211 into master #209 (templates, categories and comments over AtomPub) and #211 (AtomPub reference guide) were merged into their stacked base branches after #210 was squash-merged into master, so their changes never reached master. This commit applies them: the tree is that of replace-propono-atompub at 95d2419ea, which is master plus #209 and #211. --- CHANGES.md | 15 + .../weblogger/business/WeblogManager.java | 7 + .../jpa/JPAWeblogEntryManagerImpl.java | 2 +- .../business/jpa/JPAWeblogManagerImpl.java | 14 + .../themes/TemplateRuleException.java | 73 +++ .../business/themes/WeblogTemplateEditor.java | 231 ++++++++ .../pojos/wrapper/WeblogCategoryWrapper.java | 7 +- .../ui/struts2/editor/CategoryEdit.java | 5 + .../ui/struts2/editor/TemplateEdit.java | 32 +- .../ui/struts2/editor/Templates.java | 185 ++----- .../atomprotocol/AtomCategories.java | 13 + .../atomprotocol/AtomConstants.java | 9 + .../webservices/atomprotocol/AtomEntry.java | 55 +- .../webservices/atomprotocol/AtomPerson.java | 9 + .../webservices/atomprotocol/AtomReader.java | 19 + .../webservices/atomprotocol/AtomRequest.java | 5 + .../webservices/atomprotocol/AtomWriter.java | 48 ++ .../atomprotocol/CategoryCollection.java | 246 +++++++++ .../atomprotocol/CollectionSupport.java | 135 +++++ .../atomprotocol/CommentCollection.java | 262 +++++++++ .../atomprotocol/RollerAtomHandler.java | 87 ++- .../atomprotocol/RollerAtomService.java | 69 ++- .../atomprotocol/RollerAtomServlet.java | 5 + .../atomprotocol/TemplateCollection.java | 299 ++++++++++ .../resources/ApplicationResources.properties | 1 + .../themes/WeblogTemplateEditorTest.java | 208 +++++++ .../wrapper/WeblogCategoryWrapperTest.java | 46 ++ .../atomprotocol/AtomPubDocExamplesTest.java | 487 ++++++++++++++++ .../atomprotocol/AtomReaderTest.java | 38 ++ .../AtomSchemaValidationTest.java | 109 +++- .../atomprotocol/AtomWriterTest.java | 19 + .../atomprotocol/RelaxNgValidator.java | 72 +++ .../atomprotocol/RollerAtomHandlerTest.java | 48 ++ .../RollerAtomManagementTest.java | 520 ++++++++++++++++++ .../atomprotocol/RollerAtomProtocolTest.java | 24 +- .../atomprotocol/RollerAtomServletTest.java | 39 ++ assembly-release/src/main/assembly/binary.xml | 4 + docs/README.md | 1 + docs/atompub/examples/README.txt | 4 + docs/atompub/examples/categories.xml | 6 + docs/atompub/examples/category-entry.xml | 14 + docs/atompub/examples/category-request.xml | 11 + docs/atompub/examples/comment-entry.xml | 21 + .../examples/comment-status-request.xml | 10 + docs/atompub/examples/comments-feed.xml | 27 + docs/atompub/examples/entries-feed.xml | 27 + docs/atompub/examples/entry-request.xml | 15 + docs/atompub/examples/entry-response.xml | 20 + docs/atompub/examples/media-link-entry.xml | 14 + docs/atompub/examples/service.xml | 34 ++ docs/atompub/examples/template-entry.xml | 19 + docs/atompub/examples/template-request.xml | 14 + docs/atompub/roller-atompub.rnc | 58 ++ docs/roller-atompub-guide.adoc | 508 +++++++++++++++++ docs/roller-user-guide.adoc | 6 + pom.xml | 2 + 56 files changed, 4033 insertions(+), 225 deletions(-) create mode 100644 app/src/main/java/org/apache/roller/weblogger/business/themes/TemplateRuleException.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditor.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CategoryCollection.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CollectionSupport.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CommentCollection.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/TemplateCollection.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditorTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapperTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomManagementTest.java create mode 100644 docs/atompub/examples/README.txt create mode 100644 docs/atompub/examples/categories.xml create mode 100644 docs/atompub/examples/category-entry.xml create mode 100644 docs/atompub/examples/category-request.xml create mode 100644 docs/atompub/examples/comment-entry.xml create mode 100644 docs/atompub/examples/comment-status-request.xml create mode 100644 docs/atompub/examples/comments-feed.xml create mode 100644 docs/atompub/examples/entries-feed.xml create mode 100644 docs/atompub/examples/entry-request.xml create mode 100644 docs/atompub/examples/entry-response.xml create mode 100644 docs/atompub/examples/media-link-entry.xml create mode 100644 docs/atompub/examples/service.xml create mode 100644 docs/atompub/examples/template-entry.xml create mode 100644 docs/atompub/examples/template-request.xml create mode 100644 docs/atompub/roller-atompub.rnc create mode 100644 docs/roller-atompub-guide.adoc diff --git a/CHANGES.md b/CHANGES.md index 0d85174854..26460a850a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,21 @@ ## 7.0.0 (in development) +### Improvements + +- **AtomPub can manage templates, categories and comments.** New collections + in each weblog's workspace, documented in the user guide: + - Templates (standard and mobile versions) for weblog administrators, + when the weblog uses a custom theme. + - Categories, including an RFC 5023 category document at + `/{handle}/categories.atomcat`. The entries collection lists its + categories inline, as before, and also references that document. + - Comment moderation: list, change status, delete. + +- **New AtomPub Guide** (`docs/roller-atompub-guide.adoc`). It documents + every AtomPub URI, permission, element and status code, with examples + that the test suite checks against the code. + ### Behaviour changes worth reading before upgrading - **AtomPub no longer uses ROME Propono.** Roller now has its own AtomPub diff --git a/app/src/main/java/org/apache/roller/weblogger/business/WeblogManager.java b/app/src/main/java/org/apache/roller/weblogger/business/WeblogManager.java index 2b65f371ba..0f862d1eaf 100644 --- a/app/src/main/java/org/apache/roller/weblogger/business/WeblogManager.java +++ b/app/src/main/java/org/apache/roller/weblogger/business/WeblogManager.java @@ -200,6 +200,13 @@ WeblogTemplate getTemplateByLink(Weblog w, String p) */ void saveTemplateRendition(CustomTemplateRendition templateCode) throws WebloggerException; + /** + * Remove a template's mobile rendition. The template then renders its + * standard rendition for mobile visitors too. The standard rendition + * cannot be removed. + */ + void removeTemplateRendition(CustomTemplateRendition rendition) throws WebloggerException; + /** * Get all custom templates for a weblog */ diff --git a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogEntryManagerImpl.java b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogEntryManagerImpl.java index def32afeee..477179a3f7 100644 --- a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogEntryManagerImpl.java +++ b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogEntryManagerImpl.java @@ -615,7 +615,7 @@ public boolean isDuplicateWeblogCategoryName(WeblogCategory cat) @Override public boolean isWeblogCategoryInUse(WeblogCategory cat) throws WebloggerException { - if (cat.getWeblog().getBloggerCategory().equals(cat)) { + if (cat.equals(cat.getWeblog().getBloggerCategory())) { return true; } TypedQuery q = strategy.getNamedQuery("WeblogEntry.getByCategory", WeblogEntry.class); diff --git a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogManagerImpl.java b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogManagerImpl.java index d4304b4587..50e96eef75 100644 --- a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogManagerImpl.java +++ b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogManagerImpl.java @@ -62,6 +62,7 @@ import org.apache.roller.weblogger.pojos.WeblogEntryTag; import org.apache.roller.weblogger.pojos.WeblogEntryTagAggregate; import org.apache.roller.weblogger.pojos.WeblogPermission; +import org.apache.roller.weblogger.pojos.TemplateRendition.RenditionType; import org.apache.roller.weblogger.pojos.WeblogTemplate; @@ -248,6 +249,19 @@ public void saveTemplate(WeblogTemplate template) throws WebloggerException { roller.getWeblogManager().saveWeblog(template.getWeblog()); } + @Override + public void removeTemplateRendition(CustomTemplateRendition rendition) throws WebloggerException { + if (RenditionType.STANDARD.equals(rendition.getType())) { + throw new WebloggerException("The standard rendition of a template cannot be removed"); + } + WeblogTemplate template = rendition.getWeblogTemplate(); + template.getTemplateRenditions().remove(rendition); + this.strategy.remove(rendition); + + // update weblog last modified date. date updated by saveWeblog() + roller.getWeblogManager().saveWeblog(template.getWeblog()); + } + @Override public void saveTemplateRendition(CustomTemplateRendition rendition) throws WebloggerException { this.strategy.store(rendition); diff --git a/app/src/main/java/org/apache/roller/weblogger/business/themes/TemplateRuleException.java b/app/src/main/java/org/apache/roller/weblogger/business/themes/TemplateRuleException.java new file mode 100644 index 0000000000..20ed77c03d --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/business/themes/TemplateRuleException.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.business.themes; + +import java.util.List; + +/** + * Thrown by {@link WeblogTemplateEditor} when a template change breaks one or + * more template rules. Each violation carries a message key from + * ApplicationResources and its arguments. + */ +public class TemplateRuleException extends Exception { + + private static final long serialVersionUID = 1L; + + /** One broken rule. {@code conflict} is true when another template is in the way. */ + public static final class Violation { + private final String messageKey; + private final List args; + private final boolean conflict; + + public Violation(String messageKey, List args, boolean conflict) { + this.messageKey = messageKey; + this.args = List.copyOf(args); + this.conflict = conflict; + } + + public String getMessageKey() { + return messageKey; + } + + public List getArgs() { + return args; + } + + public boolean isConflict() { + return conflict; + } + } + + private final List violations; + + public TemplateRuleException(List violations) { + super(violations.isEmpty() ? "Template rule violation" + : violations.get(0).getMessageKey() + " " + violations.get(0).getArgs()); + this.violations = List.copyOf(violations); + } + + public List getViolations() { + return violations; + } + + /** True when any violation is a conflict with an existing template. */ + public boolean isConflict() { + return violations.stream().anyMatch(Violation::isConflict); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditor.java b/app/src/main/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditor.java new file mode 100644 index 0000000000..1631ca181e --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditor.java @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.business.themes; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import org.apache.roller.util.RollerConstants; +import org.apache.roller.weblogger.WebloggerException; +import org.apache.roller.weblogger.business.WeblogManager; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.pojos.CustomTemplateRendition; +import org.apache.roller.weblogger.pojos.TemplateRendition.RenditionType; +import org.apache.roller.weblogger.pojos.TemplateRendition.TemplateLanguage; +import org.apache.roller.weblogger.pojos.ThemeTemplate; +import org.apache.roller.weblogger.pojos.ThemeTemplate.ComponentType; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogTemplate; +import org.apache.roller.weblogger.pojos.WeblogTheme; +import org.apache.roller.weblogger.util.cache.CacheManager; + +/** + * The rules for adding, changing and removing a weblog's templates. The + * template editor pages and the AtomPub template collection both use this + * class, so they enforce the same rules. Callers flush the session. + */ +public class WeblogTemplateEditor { + + private final Weblogger roller; + + public WeblogTemplateEditor(Weblogger roller) { + this.roller = roller; + } + + /** + * The template actions that may still be added to the weblog. A custom + * theme may have one template per non-custom action; a shared theme may + * add only custom templates and, if it has none yet, a Weblog template. + */ + public Set availableActions(Weblog weblog) throws WebloggerException { + List templates = roller.getWeblogManager().getTemplates(weblog); + Set actions = EnumSet.of(ComponentType.CUSTOM); + if (WeblogTheme.CUSTOM.equals(weblog.getEditorTheme())) { + actions.addAll(EnumSet.of(ComponentType.PERMALINK, ComponentType.SEARCH, + ComponentType.WEBLOG, ComponentType.TAGSINDEX)); + for (WeblogTemplate template : templates) { + if (!ComponentType.CUSTOM.equals(template.getAction())) { + actions.remove(template.getAction()); + } + } + } else { + actions.add(ComponentType.WEBLOG); + for (WeblogTemplate template : templates) { + if (ComponentType.WEBLOG.equals(template.getAction())) { + actions.remove(ComponentType.WEBLOG); + break; + } + } + } + return actions; + } + + /** Checks the name and action of a template that is about to be added. */ + public List validateNew(Weblog weblog, + ComponentType action, String name) throws WebloggerException { + List violations = new ArrayList<>(); + if (StringUtils.isEmpty(name)) { + violations.add(invalid("Template.error.nameNull")); + } else if (name.length() > RollerConstants.TEXTWIDTH_255) { + violations.add(invalid("Template.error.nameSize")); + } + if (action == null) { + violations.add(invalid("Template.error.actionNull")); + } else if (!availableActions(weblog).contains(action)) { + violations.add(new TemplateRuleException.Violation( + "Template.error.actionNotAvailable", + List.of(action.getReadableName()), true)); + } + if (StringUtils.isNotEmpty(name) + && roller.getWeblogManager().getTemplateByName(weblog, name) != null) { + violations.add(conflict(name)); + } + return violations; + } + + /** + * Adds a template with a standard rendition and, when + * {@code mobileSource} is not null, a mobile rendition. + */ + public WeblogTemplate create(Weblog weblog, ComponentType action, String name, + String standardSource, String mobileSource) + throws TemplateRuleException, WebloggerException { + + List violations = validateNew(weblog, action, name); + if (!violations.isEmpty()) { + throw new TemplateRuleException(violations); + } + + WeblogManager mgr = roller.getWeblogManager(); + WeblogTemplate template = new WeblogTemplate(); + template.setWeblog(weblog); + template.setAction(action); + template.setName(name); + template.setHidden(false); + template.setNavbar(false); + template.setLastModified(new Date()); + if (ComponentType.CUSTOM.equals(action)) { + template.setLink(name); + } + // Always keep a Weblog main page + if (ComponentType.WEBLOG.equals(action)) { + template.setName(WeblogTemplate.DEFAULT_PAGE); + } + mgr.saveTemplate(template); + + saveRendition(template, RenditionType.STANDARD, standardSource); + if (mobileSource != null) { + saveRendition(template, RenditionType.MOBILE, mobileSource); + } + + // A new Weblog template becomes the weblog's default page + if (WeblogTemplate.DEFAULT_PAGE.equals(template.getName())) { + mgr.saveWeblog(weblog); + } + return template; + } + + /** + * Checks a new name and link for an existing template: neither may be + * used by another template of the same weblog. + */ + public List validateUpdate(Weblog weblog, + WeblogTemplate template, String newName, String newLink) throws WebloggerException { + List violations = new ArrayList<>(); + if (newName != null && newName.length() > RollerConstants.TEXTWIDTH_255) { + violations.add(invalid("Template.error.nameSize")); + } + if (newLink != null && newLink.length() > RollerConstants.TEXTWIDTH_255) { + violations.add(invalid("Template.error.linkSize")); + } + WeblogManager mgr = roller.getWeblogManager(); + if (newName != null && !newName.equals(template.getName()) + && mgr.getTemplateByName(weblog, newName) != null) { + violations.add(conflict(newName)); + } + if (StringUtils.isNotEmpty(newLink) && !newLink.equals(template.getLink()) + && mgr.getTemplateByLink(weblog, newLink) != null) { + violations.add(conflict(newLink)); + } + return violations; + } + + /** + * Sets the source of a rendition, creating the rendition when the + * template does not have it yet. + */ + public void saveRendition(WeblogTemplate template, RenditionType type, String source) + throws WebloggerException { + CustomTemplateRendition rendition = template.getTemplateRendition(type); + if (rendition == null) { + rendition = new CustomTemplateRendition(template, type); + rendition.setTemplateLanguage(TemplateLanguage.VELOCITY); + } + rendition.setTemplate(source != null ? source : ""); + roller.getWeblogManager().saveTemplateRendition(rendition); + } + + /** Removes the template's mobile rendition, if it has one. */ + public void removeMobileRendition(WeblogTemplate template) throws WebloggerException { + CustomTemplateRendition mobile = template.getTemplateRendition(RenditionType.MOBILE); + if (mobile != null) { + roller.getWeblogManager().removeTemplateRendition(mobile); + } + } + + /** + * Removes a template. A required template of a custom theme cannot be + * removed. Removing the Weblog template also removes the theme's custom + * stylesheet. + */ + public void remove(Weblog weblog, WeblogTemplate template) + throws TemplateRuleException, WebloggerException { + if (template.isRequired() && WeblogTheme.CUSTOM.equals(weblog.getEditorTheme())) { + throw new TemplateRuleException(Collections.singletonList( + new TemplateRuleException.Violation( + "editPages.remove.requiredTemplate", List.of(), true))); + } + WeblogManager mgr = roller.getWeblogManager(); + if (WeblogTemplate.DEFAULT_PAGE.equals(template.getName())) { + ThemeTemplate stylesheet = weblog.getTheme().getStylesheet(); + if (stylesheet != null) { + WeblogTemplate css = mgr.getTemplateByLink(weblog, stylesheet.getLink()); + if (css != null) { + mgr.removeTemplate(css); + } + } + } + CacheManager.invalidate(template); + mgr.removeTemplate(template); + } + + private static TemplateRuleException.Violation invalid(String key) { + return new TemplateRuleException.Violation(key, List.of(), false); + } + + private static TemplateRuleException.Violation conflict(String value) { + return new TemplateRuleException.Violation( + "pagesForm.error.alreadyExists", List.of(value), true); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapper.java b/app/src/main/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapper.java index 4f99ad57fc..e2d62fccd4 100644 --- a/app/src/main/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapper.java +++ b/app/src/main/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapper.java @@ -23,6 +23,7 @@ import org.apache.roller.weblogger.WebloggerException; import org.apache.roller.weblogger.business.URLStrategy; import org.apache.roller.weblogger.pojos.WeblogCategory; +import org.apache.roller.weblogger.util.CommentAuthorUrl; /** @@ -69,8 +70,12 @@ public String getDescription() { } + /** + * The image URL when it is an http or https URL, otherwise null. Checked + * again here in case it was stored before the current rules. + */ public String getImage() { - return this.pojo.getImage(); + return CommentAuthorUrl.normalize(this.pojo.getImage()); } public WeblogWrapper getWebsite() { diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/CategoryEdit.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/CategoryEdit.java index f12f6abf68..5913de08d0 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/CategoryEdit.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/CategoryEdit.java @@ -30,6 +30,7 @@ import org.apache.roller.weblogger.pojos.WeblogCategory; import org.apache.roller.weblogger.pojos.WeblogPermission; import org.apache.roller.weblogger.ui.struts2.util.UIAction; +import org.apache.roller.weblogger.util.CommentAuthorUrl; import org.apache.roller.weblogger.util.cache.CacheManager; import org.apache.struts2.interceptor.validation.SkipValidation; @@ -162,6 +163,10 @@ public void myValidate() { addError("categoryForm.error.duplicateName", bean.getName()); } } + if (StringUtils.isNotBlank(bean.getImage()) + && CommentAuthorUrl.normalize(bean.getImage()) == null) { + addError("Category.error.imageBad"); + } } public CategoryBean getBean() { diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/TemplateEdit.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/TemplateEdit.java index a1f95cebb0..868b7f7866 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/TemplateEdit.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/TemplateEdit.java @@ -23,6 +23,8 @@ import org.apache.commons.logging.LogFactory; import org.apache.roller.weblogger.WebloggerException; import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.business.themes.TemplateRuleException; +import org.apache.roller.weblogger.business.themes.WeblogTemplateEditor; import org.apache.roller.weblogger.pojos.TemplateRendition.TemplateLanguage; import org.apache.roller.weblogger.pojos.WeblogTemplate; import org.apache.roller.weblogger.ui.struts2.util.UIAction; @@ -148,30 +150,14 @@ public String save() { } private void myValidate() { - - // if name changed make sure there isn't a conflict - if (!getTemplate().getName().equals(getBean().getName())) { - try { - if (WebloggerFactory.getWeblogger().getWeblogManager() - .getTemplateByName(getActionWeblog(), getBean().getName()) != null) { - addError("pagesForm.error.alreadyExists", getBean().getName()); - } - } catch (WebloggerException ex) { - log.error("Error checking page name uniqueness", ex); - } - } - - // if link changed make sure there isn't a conflict - if (!StringUtils.isEmpty(getBean().getLink()) && - !getBean().getLink().equals(getTemplate().getLink())) { - try { - if (WebloggerFactory.getWeblogger().getWeblogManager() - .getTemplateByLink(getActionWeblog(), getBean().getLink()) != null) { - addError("pagesForm.error.alreadyExists", getBean().getLink()); - } - } catch (WebloggerException ex) { - log.error("Error checking page link uniqueness", ex); + try { + for (TemplateRuleException.Violation violation : new WeblogTemplateEditor( + WebloggerFactory.getWeblogger()).validateUpdate(getActionWeblog(), + getTemplate(), getBean().getName(), getBean().getLink())) { + addError(violation.getMessageKey(), violation.getArgs()); } + } catch (WebloggerException ex) { + log.error("Error checking template name and link uniqueness", ex); } } diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Templates.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Templates.java index 157e8f4092..5346224de5 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Templates.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/Templates.java @@ -18,24 +18,19 @@ package org.apache.roller.weblogger.ui.struts2.editor; -import org.apache.commons.lang3.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.apache.roller.util.RollerConstants; import org.apache.roller.weblogger.WebloggerException; -import org.apache.roller.weblogger.business.WeblogManager; import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.business.themes.TemplateRuleException; +import org.apache.roller.weblogger.business.themes.WeblogTemplateEditor; import org.apache.roller.weblogger.pojos.*; -import org.apache.roller.weblogger.pojos.TemplateRendition.RenditionType; -import org.apache.roller.weblogger.pojos.TemplateRendition.TemplateLanguage; import org.apache.roller.weblogger.pojos.ThemeTemplate.ComponentType; import org.apache.roller.weblogger.ui.struts2.util.UIAction; -import org.apache.roller.weblogger.util.cache.CacheManager; import org.apache.struts2.convention.annotation.AllowedMethods; import java.util.ArrayList; import java.util.Collections; -import java.util.Date; import java.util.EnumMap; import java.util.List; import java.util.Map; @@ -88,34 +83,17 @@ public String execute() { // build list of action types that may be added Map actionsMap = new EnumMap<>(ComponentType.class); - addComponentTypeToMap(actionsMap, ComponentType.CUSTOM); - - if (WeblogTheme.CUSTOM.equals(getActionWeblog().getEditorTheme())) { - - // if the weblog is using a custom theme then determine which - // action templates are still available to be created - addComponentTypeToMap(actionsMap, ComponentType.PERMALINK); - addComponentTypeToMap(actionsMap, ComponentType.SEARCH); - addComponentTypeToMap(actionsMap, ComponentType.WEBLOG); - addComponentTypeToMap(actionsMap, ComponentType.TAGSINDEX); - - for (WeblogTemplate tmpPage : getTemplates()) { - if (!ComponentType.CUSTOM.equals(tmpPage.getAction())) { - actionsMap.remove(tmpPage.getAction()); - } - } - } else { - // Make sure we have an option for the default web page - addComponentTypeToMap(actionsMap, ComponentType.WEBLOG); - if (getNewTmplAction() == null) { - setNewTmplAction(ComponentType.WEBLOG); - } - for (WeblogTemplate tmpPage : getTemplates()) { - if (ComponentType.WEBLOG.equals(tmpPage.getAction())) { - actionsMap.remove(ComponentType.WEBLOG); - setNewTmplAction(null); - break; + for (ComponentType action : templateEditor().availableActions(getActionWeblog())) { + addComponentTypeToMap(actionsMap, action); + } + if (!WeblogTheme.CUSTOM.equals(getActionWeblog().getEditorTheme())) { + // Preselect the default web page while it can still be added + if (actionsMap.containsKey(ComponentType.WEBLOG)) { + if (getNewTmplAction() == null) { + setNewTmplAction(ComponentType.WEBLOG); } + } else { + setNewTmplAction(null); } } setAvailableActions(actionsMap); @@ -138,66 +116,22 @@ private void addComponentTypeToMap(Map map, ComponentType */ public String add() { - // validation - myValidate(); - - if (!hasActionErrors()) { - try { - - WeblogTemplate newTemplate = new WeblogTemplate(); - newTemplate.setWeblog(getActionWeblog()); - newTemplate.setAction(getNewTmplAction()); - newTemplate.setName(getNewTmplName()); - newTemplate.setHidden(false); - newTemplate.setNavbar(false); - newTemplate.setLastModified(new Date()); - - if (ComponentType.CUSTOM.equals(getNewTmplAction())) { - newTemplate.setLink(getNewTmplName()); - } - - // Make sure we have always have a Weblog main page. Stops - // deleting main page in custom theme mode also. - if (ComponentType.WEBLOG.equals(getNewTmplAction())) { - newTemplate.setName(WeblogTemplate.DEFAULT_PAGE); - } - - // save the new Template - WebloggerFactory.getWeblogger().getWeblogManager().saveTemplate(newTemplate); - - // Create weblog template renditions for available types. - CustomTemplateRendition standardRendition = - new CustomTemplateRendition( newTemplate, RenditionType.STANDARD); - standardRendition.setTemplate(getText("pageForm.newTemplateContent")); - standardRendition.setTemplateLanguage(TemplateLanguage.VELOCITY); - WebloggerFactory.getWeblogger().getWeblogManager().saveTemplateRendition(standardRendition); - - /* TODO: need a way for user to specify dual or single template via UI - CustomTemplateRendition mobileRendition = new CustomTemplateRendition( - newTemplate.getId(), RenditionType.MOBILE); - mobileRendition.setTemplate(newTemplate.getContents()); - mobileRendition.setTemplateLanguage(TemplateLanguage.VELOCITY); - WebloggerFactory.getWeblogger().getWeblogManager() - .saveTemplateRendition(mobileRendition); - */ - - // if this person happened to create a Weblog template from - // scratch then make sure and set the defaultPageId. - if (WeblogTemplate.DEFAULT_PAGE.equals(newTemplate.getName())) { - WebloggerFactory.getWeblogger().getWeblogManager().saveWeblog(getActionWeblog()); - } + try { + templateEditor().create(getActionWeblog(), getNewTmplAction(), getNewTmplName(), + getText("pageForm.newTemplateContent"), null); - // flush results to db - WebloggerFactory.getWeblogger().flush(); + // flush results to db + WebloggerFactory.getWeblogger().flush(); - // reset form fields - setNewTmplName(null); - setNewTmplAction(null); + // reset form fields + setNewTmplName(null); + setNewTmplAction(null); - } catch (WebloggerException ex) { - log.error("Error adding new template for weblog - " + getActionWeblog().getHandle(), ex); - addError("Error adding new template - check Roller logs"); - } + } catch (TemplateRuleException ex) { + addErrors(ex); + } catch (WebloggerException ex) { + log.error("Error adding new template for weblog - " + getActionWeblog().getHandle(), ex); + addError("Error adding new template - check Roller logs"); } return execute(); @@ -217,41 +151,10 @@ public String remove() { if (template != null) { try { - if (!template.isRequired() - || !WeblogTheme.CUSTOM.equals(getActionWeblog().getEditorTheme())) { - - WeblogManager mgr = WebloggerFactory.getWeblogger().getWeblogManager(); - - // if weblog template remove custom style sheet also - if (template.getName().equals(WeblogTemplate.DEFAULT_PAGE)) { - - ThemeTemplate stylesheet = getActionWeblog().getTheme().getStylesheet(); - - // Delete style sheet if the same name - if (stylesheet != null - && getActionWeblog().getTheme().getStylesheet() != null - && stylesheet.getLink().equals( - getActionWeblog().getTheme().getStylesheet().getLink())) { - - // Same so OK to delete - WeblogTemplate css = - mgr.getTemplateByLink(getActionWeblog(), stylesheet.getLink()); - - if (css != null) { - mgr.removeTemplate(css); - } - } - } - - // notify cache - CacheManager.invalidate(template); - mgr.removeTemplate(template); - WebloggerFactory.getWeblogger().flush(); - - } else { - addError("editPages.remove.requiredTemplate"); - } - + templateEditor().remove(getActionWeblog(), template); + WebloggerFactory.getWeblogger().flush(); + } catch (TemplateRuleException ex) { + addErrors(ex); } catch (Exception ex) { log.error("Error removing page - " + getRemoveId(), ex); addError("editPages.remove.error"); @@ -263,32 +166,14 @@ && getActionWeblog().getTheme().getStylesheet() != null return execute(); } - // validation when adding a new template - private void myValidate() { - - // make sure name is non-null and within proper size - if (StringUtils.isEmpty(getNewTmplName())) { - addError("Template.error.nameNull"); - } else if (getNewTmplName().length() > RollerConstants.TEXTWIDTH_255) { - addError("Template.error.nameSize"); - } - - // make sure action is a valid - if (getNewTmplAction() == null) { - addError("Template.error.actionNull"); - } + private WeblogTemplateEditor templateEditor() { + return new WeblogTemplateEditor(WebloggerFactory.getWeblogger()); + } - // check if template by that name already exists - try { - WeblogTemplate existingPage = WebloggerFactory.getWeblogger().getWeblogManager() - .getTemplateByName(getActionWeblog(), getNewTmplName()); - if (existingPage != null) { - addError("pagesForm.error.alreadyExists", getNewTmplName()); - } - } catch (WebloggerException ex) { - log.error("Error checking for existing template", ex); + private void addErrors(TemplateRuleException ex) { + for (TemplateRuleException.Violation violation : ex.getViolations()) { + addError(violation.getMessageKey(), violation.getArgs()); } - } /** diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java index 02e04c33ec..b0be73ccde 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java @@ -29,6 +29,7 @@ public class AtomCategories { private boolean fixed; private String scheme; + private String href; private final List categories = new ArrayList<>(); public boolean isFixed() { @@ -47,6 +48,18 @@ public void setScheme(String scheme) { this.scheme = scheme; } + /** + * The URI of an out-of-line category document. When set, the service + * document writes {@code } instead of the list. + */ + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } + public List getCategories() { return categories; } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomConstants.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomConstants.java index 0e747f07c2..6cae609149 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomConstants.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomConstants.java @@ -31,6 +31,15 @@ private AtomConstants() { /** Atom Publishing Protocol namespace (RFC 5023). */ public static final String APP_NS = "http://www.w3.org/2007/app"; + /** Roller's AtomPub extension namespace (templates, categories, comments). */ + public static final String ROLLER_NS = "http://roller.apache.org/ns/atompub/1.0"; + + /** Atom Threading Extensions namespace (RFC 4685). */ + public static final String THREAD_NS = "http://purl.org/syndication/thread/1.0"; + + /** Media type for an APP category document. */ + public static final String CATEGORIES_MEDIA_TYPE = "application/atomcat+xml;charset=utf-8"; + /** Media type for an Atom entry. */ public static final String ENTRY_MEDIA_TYPE = "application/atom+xml;type=entry;charset=utf-8"; diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java index 96b0005676..b9b7fa4b72 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java @@ -19,11 +19,16 @@ import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * An atom:entry. The {@code draft} and {@code edited} fields carry the APP - * control extension (app:control/app:draft and app:edited). + * control extension (app:control/app:draft and app:edited). Roller's own + * extension elements ({@link AtomConstants#ROLLER_NS}) are kept by local name + * in {@link #getExtensions()}; the mobile template rendition and the + * thr:in-reply-to link (RFC 4685) have their own fields. */ public class AtomEntry { @@ -38,6 +43,10 @@ public class AtomEntry { private List authors = new ArrayList<>(); private List categories = new ArrayList<>(); private List links = new ArrayList<>(); + private Map extensions = new LinkedHashMap<>(); + private String mobileRendition; + private String inReplyToRef; + private String inReplyToHref; public String getId() { return id; @@ -127,6 +136,50 @@ public void setLinks(List links) { this.links = links; } + /** Simple roller:* extension values, by local name, in document order. */ + public Map getExtensions() { + return extensions; + } + + public String getExtension(String name) { + return extensions.get(name); + } + + public void setExtension(String name, String value) { + if (value == null) { + extensions.remove(name); + } else { + extensions.put(name, value); + } + } + + /** Source of the mobile template rendition, or null when there is none. */ + public String getMobileRendition() { + return mobileRendition; + } + + public void setMobileRendition(String mobileRendition) { + this.mobileRendition = mobileRendition; + } + + /** The thr:in-reply-to ref (the parent's atom:id), or null. */ + public String getInReplyToRef() { + return inReplyToRef; + } + + public void setInReplyToRef(String inReplyToRef) { + this.inReplyToRef = inReplyToRef; + } + + /** The thr:in-reply-to href, or null. */ + public String getInReplyToHref() { + return inReplyToHref; + } + + public void setInReplyToHref(String inReplyToHref) { + this.inReplyToHref = inReplyToHref; + } + /** Return the href of the first link with the given rel, or null. */ public String getLinkHref(String rel) { if (links != null) { diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java index 2b713b222b..8e4bc1176b 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java @@ -24,6 +24,7 @@ public class AtomPerson { private String name; private String email; + private String uri; public String getName() { return name; @@ -33,6 +34,14 @@ public void setName(String name) { this.name = name; } + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + public String getEmail() { return email; } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java index 4940d78214..b8c7e38cc1 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java @@ -19,6 +19,8 @@ import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ROLLER_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.THREAD_NS; import java.io.InputStream; import java.time.Instant; @@ -160,6 +162,23 @@ private void readEntryChild(XMLStreamReader r, AtomEntry entry) throws XMLStream } } return; + } else if (ROLLER_NS.equals(ns)) { + if ("rendition".equals(name)) { + String type = r.getAttributeValue(null, "type"); + String value = r.getElementText(); + if ("mobile".equals(type)) { + entry.setMobileRendition(value); + } + } else { + // Roller extension values are text; markup must be escaped + entry.setExtension(name, r.getElementText()); + } + return; + } else if (THREAD_NS.equals(ns) && "in-reply-to".equals(name)) { + entry.setInReplyToRef(r.getAttributeValue(null, "ref")); + entry.setInReplyToHref(r.getAttributeValue(null, "href")); + skipElement(r); + return; } skipElement(r); } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java index a0651fedcc..694fddefc9 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java @@ -63,6 +63,11 @@ public String getHeader(String name) { return request.getHeader(name); } + /** A query string parameter, or null. */ + public String getParameter(String name) { + return request.getParameter(name); + } + public String getContentType() { return request.getContentType(); } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java index 2c4c7eab1e..c309dd61e7 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java @@ -19,11 +19,14 @@ import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ROLLER_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.THREAD_NS; import java.io.OutputStream; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Date; +import java.util.Map; import javax.xml.stream.XMLOutputFactory; import javax.xml.stream.XMLStreamException; @@ -55,6 +58,20 @@ public void writeEntry(OutputStream out, AtomEntry entry) throws AtomException { w -> writeEntryBody(w, entry)); } + /** Writes an APP category document (RFC 5023 section 7). */ + public void writeCategoriesDoc(OutputStream out, AtomCategories cats) throws AtomException { + writeDocument(out, APP_NS, "categories", "atom", ATOM_NS, + "Error serializing category document", w -> { + w.writeAttribute("fixed", cats.isFixed() ? "yes" : "no"); + if (cats.getScheme() != null) { + w.writeAttribute("scheme", cats.getScheme()); + } + for (AtomCategory cat : cats.getCategories()) { + writeCategory(w, cat); + } + }); + } + public void writeFeed(OutputStream out, AtomFeed feed) throws AtomException { writeDocument(out, ATOM_NS, "feed", "app", APP_NS, "Error serializing Atom feed", w -> { writeAtomText(w, "id", feed.getId()); @@ -106,6 +123,13 @@ private void writeDocument(OutputStream out, String rootNs, String root, w.writeStartElement(rootNs, root); w.writeDefaultNamespace(rootNs); w.writeNamespace(prefix, otherNs); + if (ATOM_NS.equals(rootNs)) { + // Extension namespaces used by entries (see writeEntryBody) + w.setPrefix("roller", ROLLER_NS); + w.setPrefix("thr", THREAD_NS); + w.writeNamespace("roller", ROLLER_NS); + w.writeNamespace("thr", THREAD_NS); + } body.write(w); w.writeEndElement(); w.writeEndDocument(); @@ -136,6 +160,11 @@ private void writeCollection(XMLStreamWriter w, AtomCollection collection) w.writeEndElement(); } for (AtomCategories cats : collection.getCategories()) { + if (cats.getHref() != null) { + w.writeEmptyElement(APP_NS, "categories"); + w.writeAttribute("href", cats.getHref()); + continue; + } w.writeStartElement(APP_NS, "categories"); w.writeAttribute("fixed", cats.isFixed() ? "yes" : "no"); if (cats.getScheme() != null) { @@ -157,6 +186,7 @@ private void writeEntryBody(XMLStreamWriter w, AtomEntry entry) throws XMLStream for (AtomPerson author : entry.getAuthors()) { w.writeStartElement(ATOM_NS, "author"); writeAtomText(w, "name", author.getName()); + writeAtomText(w, "uri", author.getUri()); writeAtomText(w, "email", author.getEmail()); w.writeEndElement(); } @@ -172,6 +202,24 @@ private void writeEntryBody(XMLStreamWriter w, AtomEntry entry) throws XMLStream for (AtomLink link : entry.getLinks()) { writeLink(w, link); } + if (entry.getInReplyToRef() != null) { + w.writeEmptyElement(THREAD_NS, "in-reply-to"); + w.writeAttribute("ref", entry.getInReplyToRef()); + if (entry.getInReplyToHref() != null) { + w.writeAttribute("href", entry.getInReplyToHref()); + } + } + if (entry.getMobileRendition() != null) { + w.writeStartElement(ROLLER_NS, "rendition"); + w.writeAttribute("type", "mobile"); + w.writeCharacters(entry.getMobileRendition()); + w.writeEndElement(); + } + for (Map.Entry ext : entry.getExtensions().entrySet()) { + w.writeStartElement(ROLLER_NS, ext.getKey()); + w.writeCharacters(ext.getValue()); + w.writeEndElement(); + } // APP extensions (RFC 5023): app:edited is a child of atom:entry // (section 10.2); app:draft goes inside app:control (section 13.1) if (entry.getEdited() != null) { diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CategoryCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CategoryCollection.java new file mode 100644 index 0000000000..2a29a8db0e --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CategoryCollection.java @@ -0,0 +1,246 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.apache.roller.util.RollerConstants; +import org.apache.roller.weblogger.WebloggerException; +import org.apache.roller.weblogger.business.WeblogEntryManager; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogCategory; +import org.apache.roller.weblogger.pojos.WeblogPermission; +import org.apache.roller.weblogger.util.CommentAuthorUrl; +import org.apache.roller.weblogger.util.cache.CacheManager; + +/** + * The categories of a weblog as an AtomPub collection, for users who may + * post to the weblog. Each category is an entry: atom:title is the name, + * atom:summary the description, roller:image the image URL (http or https, + * as in the category editor); roller:position + * and roller:inUse are read-only. + * + *

A category that entries still use is removed only when the request + * names another category of the same weblog in the {@code moveTo} parameter; + * the entries move there first, and the target becomes the default (Blogger + * API) category if the removed one was. Otherwise DELETE answers 409, as it + * does for the weblog's last category. + */ +public class CategoryCollection { + + private final Weblogger roller; + private final User user; + private final String atomURL; + + public CategoryCollection(User user, String atomURL) { + this.user = user; + this.atomURL = atomURL; + this.roller = WebloggerFactory.getWeblogger(); + } + + public AtomFeed getCollection(AtomRequest areq) throws AtomException { + Weblog weblog = requireWeblog(CollectionSupport.path(areq)); + try { + AtomFeed feed = new AtomFeed(); + String uri = atomURL + "/" + weblog.getHandle() + "/categories"; + feed.setId(uri); + feed.setTitle(weblog.getName() + " categories"); + feed.setUpdated(new Date()); + feed.getLinks().add(CollectionSupport.link("self", uri)); + for (WeblogCategory category : manager().getWeblogCategories(weblog)) { + feed.getEntries().add(createAtomEntry(weblog, category)); + } + return feed; + } catch (WebloggerException e) { + throw new AtomException("Getting category collection", e); + } + } + + /** The weblog's categories as an APP category document (RFC 5023 section 7). */ + public AtomCategories getCategoriesDocument(AtomRequest areq) throws AtomException { + Weblog weblog = requireWeblog(CollectionSupport.path(areq)); + try { + return RollerAtomService.weblogCategories(weblog); + } catch (WebloggerException e) { + throw new AtomException("Getting category document", e); + } + } + + public AtomEntry getEntry(AtomRequest areq) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + return createAtomEntry(weblog, requireCategory(weblog, pathInfo)); + } catch (WebloggerException e) { + throw new AtomException("Getting category", e); + } + } + + public AtomEntry postEntry(AtomRequest areq, AtomEntry entry) throws AtomException { + Weblog weblog = requireWeblog(CollectionSupport.path(areq)); + String name = validName(entry.getTitle()); + if (weblog.hasCategory(name)) { + throw CollectionSupport.conflict("Category name already in use: " + name); + } + try { + WeblogCategory category = new WeblogCategory(); + category.setWeblog(weblog); + category.setName(name); + copyProperties(entry, category); + weblog.addCategory(category); + category.calculatePosition(); + manager().saveWeblogCategory(category); + roller.flush(); + CacheManager.invalidate(weblog); + return createAtomEntry(weblog, category); + } catch (WebloggerException e) { + throw new AtomException("Creating category", e); + } + } + + public void putEntry(AtomRequest areq, AtomEntry entry) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + WeblogCategory category = requireCategory(weblog, pathInfo); + if (entry.getTitle() != null) { + String name = validName(entry.getTitle()); + WeblogCategory existing = weblog.getWeblogCategory(name); + if (existing != null && !existing.getId().equals(category.getId())) { + throw CollectionSupport.conflict("Category name already in use: " + name); + } + category.setName(name); + } + copyProperties(entry, category); + manager().saveWeblogCategory(category); + roller.flush(); + CacheManager.invalidate(category); + } catch (WebloggerException e) { + throw new AtomException("Updating category", e); + } + } + + public void deleteEntry(AtomRequest areq) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + WeblogEntryManager mgr = manager(); + WeblogCategory category = requireCategory(weblog, pathInfo); + if (mgr.getWeblogCategories(weblog).size() < 2) { + // New entries need a category, as on the Categories page + throw CollectionSupport.conflict("A weblog must keep at least one category"); + } + String moveTo = areq.getParameter("moveTo"); + if (StringUtils.isNotEmpty(moveTo)) { + WeblogCategory target = mgr.getWeblogCategory(weblog, moveTo); + if (target == null || target.getId().equals(category.getId())) { + throw CollectionSupport.badRequest("moveTo must name another category of this weblog"); + } + mgr.moveWeblogCategoryContents(category, target); + roller.flush(); + } else if (mgr.isWeblogCategoryInUse(category)) { + throw CollectionSupport.conflict( + "Category is in use; name a category to move its entries to with moveTo"); + } + CacheManager.invalidate(category); + mgr.removeWeblogCategory(category); + roller.flush(); + } catch (WebloggerException e) { + throw new AtomException("Removing category", e); + } + } + + private AtomEntry createAtomEntry(Weblog weblog, WeblogCategory category) + throws WebloggerException { + String editURI = atomURL + "/" + weblog.getHandle() + "/category/" + category.getId(); + AtomEntry entry = new AtomEntry(); + entry.setId(editURI); + entry.setTitle(category.getName()); + entry.setUpdated(new Date()); + if (category.getDescription() != null) { + entry.setSummary(CollectionSupport.text("text", category.getDescription())); + } + entry.setExtension("image", category.getImage()); + entry.setExtension("position", Integer.toString(category.getPosition())); + entry.setExtension("inUse", Boolean.toString(manager().isWeblogCategoryInUse(category))); + List links = new ArrayList<>(); + links.add(CollectionSupport.link("edit", editURI)); + entry.setLinks(links); + return entry; + } + + /** Copies the description and image, with the category editor's rules. */ + private static void copyProperties(AtomEntry entry, WeblogCategory category) + throws AtomException { + if (entry.getSummary() != null) { + String description = entry.getSummary().getValue(); + checkLength(description, "Category description"); + category.setDescription(description); + } + if (entry.getExtensions().containsKey("image")) { + String image = StringUtils.trimToNull(entry.getExtension("image")); + if (image != null && CommentAuthorUrl.normalize(image) == null) { + throw CollectionSupport.badRequest("roller:image must be an http or https URL"); + } + checkLength(image, "roller:image"); + category.setImage(image); + } + } + + private static void checkLength(String value, String what) throws AtomException { + if (value != null && value.length() > RollerConstants.TEXTWIDTH_255) { + throw CollectionSupport.badRequest(what + " cannot be more than 255 characters"); + } + } + + /** Category names must be present and free of HTML markup, as in the category editor. */ + private static String validName(String name) throws AtomException { + String trimmed = StringUtils.trimToNull(name); + if (trimmed == null || !trimmed.equals(StringEscapeUtils.escapeHtml4(trimmed))) { + throw CollectionSupport.badRequest("Invalid category name"); + } + checkLength(trimmed, "Category name"); + return trimmed; + } + + private Weblog requireWeblog(String[] pathInfo) throws AtomException { + return CollectionSupport.requireWeblog(roller, user, pathInfo, WeblogPermission.POST); + } + + private WeblogCategory requireCategory(Weblog weblog, String[] pathInfo) + throws AtomException, WebloggerException { + WeblogCategory category = pathInfo.length > 2 + ? manager().getWeblogCategory(weblog, pathInfo[2]) : null; + if (category == null) { + throw new AtomNotFoundException("Cannot find category"); + } + return category; + } + + private WeblogEntryManager manager() { + return roller.getWeblogEntryManager(); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CollectionSupport.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CollectionSupport.java new file mode 100644 index 0000000000..47a6ae8c26 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CollectionSupport.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.webservices.atomprotocol; + +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang3.StringUtils; +import org.apache.roller.weblogger.WebloggerException; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogPermission; + +/** + * Helpers shared by the template, category and comment collections. + */ +final class CollectionSupport { + + private CollectionSupport() { + } + + /** Splits the path info; element 0 is the weblog handle, element 1 the collection. */ + static String[] path(AtomRequest areq) { + String[] pathInfo = StringUtils.split(areq.getPathInfo(), "/"); + return pathInfo != null ? pathInfo : new String[0]; + } + + /** + * Returns the weblog named by the first path element when the user holds + * {@code action} permission on it. + */ + static Weblog requireWeblog(Weblogger roller, User user, String[] pathInfo, String action) + throws AtomException { + if (pathInfo.length == 0) { + throw new AtomNotFoundException("Cannot find weblog"); + } + Weblog weblog; + try { + weblog = roller.getWeblogManager().getWeblogByHandle(pathInfo[0]); + } catch (WebloggerException e) { + throw new AtomException("Looking up weblog", e); + } + if (weblog == null) { + throw new AtomNotFoundException("Cannot find weblog: " + pathInfo[0]); + } + if (!hasPermission(user, weblog, action)) { + throw new AtomNotAuthorizedException("Not authorized to access weblog: " + pathInfo[0]); + } + return weblog; + } + + static boolean hasPermission(User user, Weblog weblog, String action) { + try { + return weblog.hasUserPermission(user, action); + } catch (Exception e) { + return false; + } + } + + /** True when the user may manage the weblog's templates and settings. */ + static boolean isAdmin(User user, Weblog weblog) { + return hasPermission(user, weblog, WeblogPermission.ADMIN); + } + + /** The paging offset in the third path element, or 0. */ + static int offset(String[] pathInfo) { + if (pathInfo.length > 2) { + try { + return Math.max(0, Integer.parseInt(pathInfo[2])); + } catch (NumberFormatException e) { + return 0; + } + } + return 0; + } + + /** A client error (HTTP 400). */ + static AtomException badRequest(String message) { + return new AtomException(message, HttpServletResponse.SC_BAD_REQUEST, null); + } + + /** A conflict with the current state of the resource (HTTP 409). */ + static AtomException conflict(String message) { + return new AtomException(message, HttpServletResponse.SC_CONFLICT, null); + } + + static AtomLink link(String rel, String href) { + AtomLink link = new AtomLink(); + link.setRel(rel); + link.setHref(href); + return link; + } + + static AtomContent text(String type, String value) { + AtomContent content = new AtomContent(); + content.setType(type); + content.setValue(value); + return content; + } + + /** The text value of an Atom text construct, or null. */ + static String value(AtomContent content) { + return content != null ? content.getValue() : null; + } + + static Boolean parseBoolean(String value) throws AtomException { + if (value == null) { + return null; + } + String v = value.trim(); + if ("true".equalsIgnoreCase(v)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(v)) { + return Boolean.FALSE; + } + throw badRequest("Expected true or false: " + value); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CommentCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CommentCollection.java new file mode 100644 index 0000000000..8b865778c6 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CommentCollection.java @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.roller.weblogger.WebloggerException; +import org.apache.roller.weblogger.business.WeblogEntryManager; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.pojos.CommentSearchCriteria; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogEntry; +import org.apache.roller.weblogger.pojos.WeblogEntryComment; +import org.apache.roller.weblogger.pojos.WeblogEntryComment.ApprovalStatus; +import org.apache.roller.weblogger.pojos.WeblogPermission; +import org.apache.roller.weblogger.util.I18nMessages; +import org.apache.roller.weblogger.util.MailUtil; +import org.apache.roller.weblogger.util.cache.CacheManager; + +/** + * The comments of a weblog as an AtomPub collection, for comment moderation + * by users who may post to the weblog. Clients can list and read comments, + * change a comment's roller:status, and delete comments; the collection does + * not accept POST. + * + *

Each comment is an entry: atom:author is the commenter, atom:content + * the comment text, atom:published the post time, thr:in-reply-to (RFC 4685) + * the weblog entry, roller:status the approval status. The list can be + * filtered with the {@code status} and {@code entry} (weblog entry id) query + * parameters and paged like the entries collection. + */ +public class CommentCollection { + + private static final Log log = LogFactory.getLog(CommentCollection.class); + + private static final int MAX_ENTRIES = 20; + + private final Weblogger roller; + private final User user; + private final String atomURL; + + public CommentCollection(User user, String atomURL) { + this.user = user; + this.atomURL = atomURL; + this.roller = WebloggerFactory.getWeblogger(); + } + + public AtomFeed getCollection(AtomRequest areq) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + int start = CollectionSupport.offset(pathInfo); + try { + CommentSearchCriteria csc = new CommentSearchCriteria(); + csc.setWeblog(weblog); + csc.setReverseChrono(true); + csc.setOffset(start); + csc.setMaxResults(MAX_ENTRIES + 1); + String status = areq.getParameter("status"); + if (StringUtils.isNotEmpty(status)) { + csc.setStatus(parseStatus(status)); + } + String entryId = areq.getParameter("entry"); + if (StringUtils.isNotEmpty(entryId)) { + WeblogEntry entry = manager().getWeblogEntry(weblog, entryId); + if (entry == null) { + throw new AtomNotFoundException("Cannot find weblog entry"); + } + csc.setEntry(entry); + } + List comments = manager().getComments(csc); + + String base = atomURL + "/" + weblog.getHandle() + "/comments/"; + String query = queryString(status, entryId); + AtomFeed feed = new AtomFeed(); + feed.setId(base + start); + feed.setTitle(weblog.getName() + " comments"); + feed.setUpdated(new Date()); + int count = 0; + for (WeblogEntryComment comment : comments) { + if (count++ >= MAX_ENTRIES) { + break; + } + feed.getEntries().add(createAtomEntry(weblog, comment)); + } + if (comments.size() > MAX_ENTRIES) { + feed.getLinks().add(CollectionSupport.link("next", + base + (start + MAX_ENTRIES) + query)); + } + if (start > 0) { + feed.getLinks().add(CollectionSupport.link("previous", + base + Math.max(0, start - MAX_ENTRIES) + query)); + } + return feed; + } catch (WebloggerException e) { + throw new AtomException("Getting comment collection", e); + } + } + + public AtomEntry getEntry(AtomRequest areq) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + return createAtomEntry(weblog, requireComment(weblog, pathInfo)); + } catch (WebloggerException e) { + throw new AtomException("Getting comment", e); + } + } + + /** + * Changes the comment's approval status. Other fields are ignored. A + * pending comment that is approved sends the approval notification, as + * on the Comments page. + */ + public void putEntry(AtomRequest areq, AtomEntry entry) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + String status = entry.getExtension("status"); + if (StringUtils.isEmpty(status)) { + throw CollectionSupport.badRequest("roller:status is required"); + } + ApprovalStatus newStatus = parseStatus(status); + try { + WeblogEntryComment comment = requireComment(weblog, pathInfo); + ApprovalStatus oldStatus = comment.getStatus(); + if (newStatus.equals(oldStatus)) { + return; + } + comment.setStatus(newStatus); + manager().saveComment(comment); + roller.flush(); + CacheManager.invalidate(weblog); + + if (ApprovalStatus.PENDING.equals(oldStatus) + && ApprovalStatus.APPROVED.equals(newStatus) + && MailUtil.isMailConfigured()) { + try { + MailUtil.sendEmailApprovalNotifications(Collections.singletonList(comment), + I18nMessages.getMessages(weblog.getLocaleInstance())); + } catch (Exception e) { + // The status change is saved; a mail failure must not undo it + log.error("Error sending comment approval notification", e); + } + } + roller.getIndexManager().addEntryReIndexOperation(comment.getWeblogEntry()); + } catch (WebloggerException e) { + throw new AtomException("Updating comment", e); + } + } + + public void deleteEntry(AtomRequest areq) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + WeblogEntryComment comment = requireComment(weblog, pathInfo); + WeblogEntry entry = comment.getWeblogEntry(); + manager().removeComment(comment); + roller.flush(); + // JPA clears a removed comment, so invalidate the whole weblog + CacheManager.invalidate(weblog); + roller.getIndexManager().addEntryReIndexOperation(entry); + } catch (WebloggerException e) { + throw new AtomException("Removing comment", e); + } + } + + private AtomEntry createAtomEntry(Weblog weblog, WeblogEntryComment comment) { + String editURI = atomURL + "/" + weblog.getHandle() + "/comment/" + comment.getId(); + AtomEntry entry = new AtomEntry(); + entry.setId(editURI); + WeblogEntry parent = comment.getWeblogEntry(); + entry.setTitle("Comment on " + parent.getTitle()); + entry.setPublished(comment.getPostTime()); + entry.setUpdated(comment.getPostTime() != null ? comment.getPostTime() : new Date()); + + AtomPerson author = new AtomPerson(); + author.setName(StringUtils.isNotEmpty(comment.getName()) ? comment.getName() : "Anonymous"); + author.setEmail(StringUtils.trimToNull(comment.getEmail())); + author.setUri(StringUtils.trimToNull(comment.getUrl())); + entry.getAuthors().add(author); + + String type = "text/html".equals(comment.getContentType()) ? "html" : "text"; + entry.setContent(CollectionSupport.text(type, comment.getContent())); + + entry.setInReplyToRef(parent.getPermalink()); + entry.setInReplyToHref(atomURL + "/" + weblog.getHandle() + "/entry/" + parent.getId()); + + entry.setExtension("status", comment.getStatus().name().toLowerCase(Locale.ROOT)); + entry.setExtension("remoteHost", comment.getRemoteHost()); + entry.setExtension("referrer", comment.getReferrer()); + + List links = new ArrayList<>(); + links.add(CollectionSupport.link("edit", editURI)); + links.add(CollectionSupport.link("related", parent.getPermalink())); + entry.setLinks(links); + return entry; + } + + private static ApprovalStatus parseStatus(String value) throws AtomException { + try { + return ApprovalStatus.valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw CollectionSupport.badRequest( + "Unknown comment status (approved, disapproved, spam, pending): " + value); + } + } + + private static String queryString(String status, String entryId) { + List params = new ArrayList<>(); + if (StringUtils.isNotEmpty(status)) { + params.add("status=" + URLEncoder.encode(status, StandardCharsets.UTF_8)); + } + if (StringUtils.isNotEmpty(entryId)) { + params.add("entry=" + URLEncoder.encode(entryId, StandardCharsets.UTF_8)); + } + return params.isEmpty() ? "" : "?" + String.join("&", params); + } + + private Weblog requireWeblog(String[] pathInfo) throws AtomException { + return CollectionSupport.requireWeblog(roller, user, pathInfo, WeblogPermission.POST); + } + + private WeblogEntryComment requireComment(Weblog weblog, String[] pathInfo) + throws AtomException, WebloggerException { + WeblogEntryComment comment = pathInfo.length > 2 + ? manager().getComment(weblog, pathInfo[2]) : null; + if (comment == null) { + throw new AtomNotFoundException("Cannot find comment"); + } + return comment; + } + + private WeblogEntryManager manager() { + return roller.getWeblogEntryManager(); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java index 2844bd1ef5..c76ac2cc97 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java @@ -176,8 +176,29 @@ public AtomServiceDoc getAtomService(AtomRequest areq) throws AtomException { * Create entry in the entry collection (a Weblogger blog has only one). */ public AtomEntry postEntry(AtomRequest areq, AtomEntry entry) throws AtomException { - EntryCollection ecol = new EntryCollection(user, atomURL); - return ecol.postEntry(areq, entry); + switch (collectionName(areq)) { + case "templates": + return new TemplateCollection(user, atomURL).postEntry(areq, entry); + case "categories": + return new CategoryCollection(user, atomURL).postEntry(areq, entry); + case "comments": + throw new AtomException("The comments collection does not accept POST", + HttpServletResponse.SC_METHOD_NOT_ALLOWED, null); + default: + EntryCollection ecol = new EntryCollection(user, atomURL); + return ecol.postEntry(areq, entry); + } + } + + /** The second path element (the collection or member type), or "". */ + private static String collectionName(AtomRequest areq) { + String[] pathInfo = StringUtils.split(areq.getPathInfo(), "/"); + return pathInfo != null && pathInfo.length > 1 ? pathInfo[1] : ""; + } + + /** True for the collections that hold only Atom entries, never media. */ + private static boolean isEntryOnlyCollection(String name) { + return "templates".equals(name) || "categories".equals(name) || "comments".equals(name); } @@ -186,6 +207,10 @@ public AtomEntry postEntry(AtomRequest areq, AtomEntry entry) throws AtomExcepti */ public AtomEntry postMedia(AtomRequest areq, AtomEntry entry) throws AtomException { + if (isEntryOnlyCollection(collectionName(areq))) { + throw new AtomException("This collection accepts only Atom entries", + HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, null); + } MediaCollection mcol = new MediaCollection(user, atomURL); return mcol.postMedia(areq, entry); } @@ -213,6 +238,15 @@ public AtomFeed getCollection(AtomRequest areq) throws AtomException { } else if (pathInfo.length > 0 && pathInfo[1].equals("resources")) { MediaCollection mcol = new MediaCollection(user, atomURL); return mcol.getCollection(areq); + + } else if (pathInfo.length > 1 && pathInfo[1].equals("templates")) { + return new TemplateCollection(user, atomURL).getCollection(areq); + + } else if (pathInfo.length > 1 && pathInfo[1].equals("categories")) { + return new CategoryCollection(user, atomURL).getCollection(areq); + + } else if (pathInfo.length > 1 && pathInfo[1].equals("comments")) { + return new CommentCollection(user, atomURL).getCollection(areq); } throw new AtomNotFoundException("Cannot find collection specified"); } @@ -232,6 +266,12 @@ public AtomEntry getEntry(AtomRequest areq) throws AtomException { } else if (pathInfo[1].equals("resource") && pathInfo[pathInfo.length - 1].endsWith(".media-link")) { MediaCollection mcol = new MediaCollection(user, atomURL); return mcol.getEntry(areq); + } else if (pathInfo[1].equals("template")) { + return new TemplateCollection(user, atomURL).getEntry(areq); + } else if (pathInfo[1].equals("category")) { + return new CategoryCollection(user, atomURL).getEntry(areq); + } else if (pathInfo[1].equals("comment")) { + return new CommentCollection(user, atomURL).getEntry(areq); } } throw new AtomNotFoundException("Cannot find specified entry/resource"); @@ -252,8 +292,25 @@ public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException * Update entry, URI like this /blog-name/entry/id */ public void putEntry(AtomRequest areq, AtomEntry entry) throws AtomException { - EntryCollection ecol = new EntryCollection(user, atomURL); - ecol.putEntry(areq, entry); + switch (collectionName(areq)) { + case "template": + new TemplateCollection(user, atomURL).putEntry(areq, entry); + return; + case "category": + new CategoryCollection(user, atomURL).putEntry(areq, entry); + return; + case "comment": + new CommentCollection(user, atomURL).putEntry(areq, entry); + return; + default: + EntryCollection ecol = new EntryCollection(user, atomURL); + ecol.putEntry(areq, entry); + } + } + + /** The out-of-line category document of a weblog's entries collection. */ + public AtomCategories getCategoriesDocument(AtomRequest areq) throws AtomException { + return new CategoryCollection(user, atomURL).getCategoriesDocument(areq); } @@ -285,6 +342,15 @@ public void deleteEntry(AtomRequest areq) throws AtomException { MediaCollection mcol = new MediaCollection(user, atomURL); mcol.deleteEntry(areq); return; + } else if (pathInfo[1].equals("template")) { + new TemplateCollection(user, atomURL).deleteEntry(areq); + return; + } else if (pathInfo[1].equals("category")) { + new CategoryCollection(user, atomURL).deleteEntry(areq); + return; + } else if (pathInfo[1].equals("comment")) { + new CommentCollection(user, atomURL).deleteEntry(areq); + return; } } throw new AtomNotFoundException("cannot find specified entry/resource"); @@ -317,7 +383,8 @@ static boolean isEntryPath(String path) { if (pathInfo == null) { return false; } - if (pathInfo.length > 2 && pathInfo[1].equals("entry")) { + if (pathInfo.length > 2 && (pathInfo[1].equals("entry") || pathInfo[1].equals("template") + || pathInfo[1].equals("category") || pathInfo[1].equals("comment"))) { return true; } if (pathInfo.length > 2 && pathInfo[1].equals("resource") && pathInfo[pathInfo.length-1].endsWith(".media-link")) { @@ -351,9 +418,19 @@ public boolean isCollectionURI(AtomRequest areq) { if (pathInfo.length > 1 && pathInfo[1].equals("categories")) { return true; } + if (pathInfo.length > 1 && (pathInfo[1].equals("templates") + || pathInfo[1].equals("comments"))) { + return true; + } return false; } + /** True if URL is a weblog's category document, /blog-name/categories.atomcat. */ + public boolean isCategoriesDocURI(AtomRequest areq) { + String[] pathInfo = StringUtils.split(areq.getPathInfo(), "/"); + return pathInfo.length == 2 && pathInfo[1].equals("categories.atomcat"); + } + //------------------------------------------------------------------ permissions diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java index 74094efbab..4bd517c9b5 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java @@ -82,20 +82,12 @@ public RollerAtomService(User user, String atomURL) throws WebloggerException, A entryCol.setHref(atomURL + "/" + weblog.getHandle() + "/entries"); entryCol.getAccepts().add("application/atom+xml;type=entry"); - // Add fixed categories using scheme that points to - // weblog because categories are weblog specific - weblog = perm.getWeblog(); - AtomCategories cats = new AtomCategories(); - cats.setFixed(true); - cats.setScheme(getWeblogCategoryScheme(weblog)); - List rollerCats = roller.getWeblogEntryManager().getWeblogCategories(weblog); - for (WeblogCategory rollerCat : rollerCats) { - AtomCategory cat = new AtomCategory(); - cat.setTerm(rollerCat.getName()); - cat.setLabel(rollerCat.getName()); - cats.getCategories().add(cat); - } - entryCol.getCategories().add(cats); + // The weblog's fixed categories, both inline (for older + // clients) and as an out-of-line category document + entryCol.getCategories().add(weblogCategories(weblog)); + AtomCategories catsDoc = new AtomCategories(); + catsDoc.setHref(atomURL + "/" + weblog.getHandle() + "/categories.atomcat"); + entryCol.getCategories().add(catsDoc); // Indicte that free form categories are allowed AtomCategories tags = new AtomCategories(); @@ -123,10 +115,59 @@ public RollerAtomService(User user, String atomURL) throws WebloggerException, A } catch (Exception e) { throw new AtomException("Creating weblog entry collection for service doc", e); } + + addManagementCollections(workspace, user, weblog, atomURL); } } } + /** + * Adds the template, category and comment collections that the user may + * use. Templates need weblog ADMIN permission and a custom theme; the + * others need POST permission. + */ + private static void addManagementCollections(AtomWorkspace workspace, User user, + Weblog weblog, String atomURL) { + String base = atomURL + "/" + weblog.getHandle(); + if (TemplateCollection.isAvailable(user, weblog)) { + workspace.getCollections().add(collection("Templates", base + "/templates", + "application/atom+xml;type=entry")); + } + if (CollectionSupport.hasPermission(user, weblog, WeblogPermission.POST)) { + workspace.getCollections().add(collection("Categories", base + "/categories", + "application/atom+xml;type=entry")); + // An empty accept list means the collection does not accept POST + workspace.getCollections().add(collection("Comments", base + "/comments", "")); + } + } + + private static AtomCollection collection(String title, String href, String accept) { + AtomCollection collection = new AtomCollection(); + collection.setTitle(title); + collection.setHref(href); + collection.getAccepts().add(accept); + return collection; + } + + /** + * The weblog's categories as a fixed category set. The scheme points to + * the weblog because categories are weblog specific. + */ + static AtomCategories weblogCategories(Weblog weblog) throws WebloggerException { + AtomCategories cats = new AtomCategories(); + cats.setFixed(true); + cats.setScheme(getWeblogCategoryScheme(weblog)); + List rollerCats = WebloggerFactory.getWeblogger() + .getWeblogEntryManager().getWeblogCategories(weblog); + for (WeblogCategory rollerCat : rollerCats) { + AtomCategory cat = new AtomCategory(); + cat.setTerm(rollerCat.getName()); + cat.setLabel(rollerCat.getName()); + cats.getCategories().add(cat); + } + return cats; + } + /** * The assembled service document. */ diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java index 4c765ec982..4dfc3755fd 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java @@ -222,6 +222,11 @@ private void doGet(RollerAtomHandler handler, AtomRequest areq, HttpServletRespo response.setContentType(AtomConstants.SERVICE_MEDIA_TYPE); new AtomWriter().writeServiceDoc(response.getOutputStream(), service); + } else if (handler.isCategoriesDocURI(areq)) { + AtomCategories cats = handler.getCategoriesDocument(areq); + response.setContentType(AtomConstants.CATEGORIES_MEDIA_TYPE); + new AtomWriter().writeCategoriesDoc(response.getOutputStream(), cats); + } else if (handler.isCollectionURI(areq)) { AtomFeed feed = handler.getCollection(areq); response.setContentType(AtomConstants.FEED_MEDIA_TYPE); diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/TemplateCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/TemplateCollection.java new file mode 100644 index 0000000000..608465734f --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/TemplateCollection.java @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import org.apache.commons.lang3.StringUtils; +import org.apache.roller.util.RollerConstants; +import org.apache.roller.weblogger.WebloggerException; +import org.apache.roller.weblogger.business.WeblogManager; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.business.themes.TemplateRuleException; +import org.apache.roller.weblogger.business.themes.WeblogTemplateEditor; +import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +import org.apache.roller.weblogger.pojos.CustomTemplateRendition; +import org.apache.roller.weblogger.pojos.TemplateRendition.RenditionType; +import org.apache.roller.weblogger.pojos.ThemeTemplate.ComponentType; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogPermission; +import org.apache.roller.weblogger.pojos.WeblogTemplate; +import org.apache.roller.weblogger.pojos.WeblogTheme; +import org.apache.roller.weblogger.util.I18nMessages; +import org.apache.roller.weblogger.util.cache.CacheManager; + +/** + * The templates of a weblog that uses a custom theme, as an AtomPub + * collection. Only weblog administrators may use it, and only while the + * weblog's theme is custom and custom themes are allowed on the site; + * otherwise the collection does not exist (404). + * + *

Each template is an entry: atom:title is the name, atom:summary the + * description, atom:content (type text) the standard rendition, and + * roller:rendition type="mobile" the mobile rendition. On PUT, a missing + * mobile rendition is left as it is and an empty one is removed. roller:action, + * roller:link, roller:navbar, roller:hidden and roller:contentType carry the + * other properties; roller:required is read-only. + */ +public class TemplateCollection { + + private final Weblogger roller; + private final User user; + private final String atomURL; + + public TemplateCollection(User user, String atomURL) { + this.user = user; + this.atomURL = atomURL; + this.roller = WebloggerFactory.getWeblogger(); + } + + /** True when the user may use the template collection of the weblog. */ + static boolean isAvailable(User user, Weblog weblog) { + return WebloggerRuntimeConfig.getBooleanProperty("themes.customtheme.allowed") + && WeblogTheme.CUSTOM.equals(weblog.getEditorTheme()) + && CollectionSupport.isAdmin(user, weblog); + } + + public AtomFeed getCollection(AtomRequest areq) throws AtomException { + Weblog weblog = requireWeblog(CollectionSupport.path(areq)); + try { + AtomFeed feed = new AtomFeed(); + feed.setId(collectionURI(weblog)); + feed.setTitle(weblog.getName() + " templates"); + feed.setUpdated(new Date()); + feed.getLinks().add(CollectionSupport.link("self", collectionURI(weblog))); + for (WeblogTemplate template : roller.getWeblogManager().getTemplates(weblog)) { + feed.getEntries().add(createAtomEntry(weblog, template)); + } + return feed; + } catch (WebloggerException e) { + throw new AtomException("Getting template collection", e); + } + } + + public AtomEntry getEntry(AtomRequest areq) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + return createAtomEntry(weblog, requireTemplate(weblog, pathInfo)); + } catch (WebloggerException e) { + throw new AtomException("Getting template", e); + } + } + + public AtomEntry postEntry(AtomRequest areq, AtomEntry entry) throws AtomException { + Weblog weblog = requireWeblog(CollectionSupport.path(areq)); + String actionName = entry.getExtension("action"); + ComponentType action = actionName == null ? ComponentType.CUSTOM : parseAction(actionName); + try { + String mobile = StringUtils.isBlank(entry.getMobileRendition()) + ? null : entry.getMobileRendition(); + WeblogTemplate template = editor().create(weblog, action, entry.getTitle(), + CollectionSupport.value(entry.getContent()), mobile); + applyProperties(weblog, template, entry); + roller.getWeblogManager().saveTemplate(template); + roller.flush(); + CacheManager.invalidate(template); + return createAtomEntry(weblog, template); + } catch (TemplateRuleException e) { + throw ruleError(weblog, e); + } catch (WebloggerException e) { + throw new AtomException("Creating template", e); + } + } + + public void putEntry(AtomRequest areq, AtomEntry entry) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + WeblogTemplate template = requireTemplate(weblog, pathInfo); + WeblogTemplateEditor editor = editor(); + if (entry.getContent() != null) { + editor.saveRendition(template, RenditionType.STANDARD, + entry.getContent().getValue()); + } + if (entry.getMobileRendition() != null) { + // An empty mobile rendition removes it; mobile visitors then + // see the standard rendition + if (entry.getMobileRendition().isBlank()) { + editor.removeMobileRendition(template); + } else { + editor.saveRendition(template, RenditionType.MOBILE, entry.getMobileRendition()); + } + } + applyProperties(weblog, template, entry); + template.setLastModified(new Date()); + roller.getWeblogManager().saveTemplate(template); + roller.flush(); + CacheManager.invalidate(template); + } catch (TemplateRuleException e) { + throw ruleError(weblog, e); + } catch (WebloggerException e) { + throw new AtomException("Updating template", e); + } + } + + public void deleteEntry(AtomRequest areq) throws AtomException { + String[] pathInfo = CollectionSupport.path(areq); + Weblog weblog = requireWeblog(pathInfo); + try { + editor().remove(weblog, requireTemplate(weblog, pathInfo)); + roller.flush(); + } catch (TemplateRuleException e) { + throw ruleError(weblog, e); + } catch (WebloggerException e) { + throw new AtomException("Removing template", e); + } + } + + /** + * Copies the name, description, link, navbar and hidden flags (custom + * templates only, as in the template editor) and the content type. + */ + private void applyProperties(Weblog weblog, WeblogTemplate template, AtomEntry entry) + throws TemplateRuleException, AtomException, WebloggerException { + String link = entry.getExtension("link"); + if (template.isCustom()) { + String name = StringUtils.isNotEmpty(entry.getTitle()) ? entry.getTitle() : null; + List violations = + editor().validateUpdate(weblog, template, name, link); + if (!violations.isEmpty()) { + throw new TemplateRuleException(violations); + } + if (name != null) { + template.setName(name); + } + if (entry.getSummary() != null) { + String description = entry.getSummary().getValue(); + if (description != null && description.length() > RollerConstants.TEXTWIDTH_255) { + throw new TemplateRuleException(Collections.singletonList( + new TemplateRuleException.Violation( + "Template.error.descriptionSize", List.of(), false))); + } + template.setDescription(description); + } + if (link != null) { + template.setLink(link); + } + Boolean navbar = CollectionSupport.parseBoolean(entry.getExtension("navbar")); + if (navbar != null) { + template.setNavbar(navbar); + } + Boolean hidden = CollectionSupport.parseBoolean(entry.getExtension("hidden")); + if (hidden != null) { + template.setHidden(hidden); + } + } + if (entry.getExtensions().containsKey("contentType")) { + // empty content type means automatic detection, as in the editor + template.setOutputContentType( + StringUtils.trimToNull(entry.getExtension("contentType"))); + } + } + + private AtomEntry createAtomEntry(Weblog weblog, WeblogTemplate template) + throws WebloggerException { + String editURI = atomURL + "/" + weblog.getHandle() + "/template/" + template.getId(); + AtomEntry entry = new AtomEntry(); + entry.setId(editURI); + entry.setTitle(template.getName()); + if (template.getDescription() != null) { + entry.setSummary(CollectionSupport.text("text", template.getDescription())); + } + Date updated = template.getLastModified() != null ? template.getLastModified() : new Date(); + entry.setUpdated(updated); + entry.setEdited(updated); + + CustomTemplateRendition standard = template.getTemplateRendition(RenditionType.STANDARD); + entry.setContent(CollectionSupport.text("text", + standard != null && standard.getTemplate() != null ? standard.getTemplate() : "")); + CustomTemplateRendition mobile = template.getTemplateRendition(RenditionType.MOBILE); + if (mobile != null) { + entry.setMobileRendition(mobile.getTemplate() != null ? mobile.getTemplate() : ""); + } + + entry.setExtension("action", template.getAction().name().toLowerCase(Locale.ROOT)); + entry.setExtension("link", template.getLink()); + entry.setExtension("navbar", Boolean.toString(template.isNavbar())); + entry.setExtension("hidden", Boolean.toString(template.isHidden())); + entry.setExtension("contentType", template.getOutputContentType()); + entry.setExtension("required", Boolean.toString(template.isRequired())); + + List links = new ArrayList<>(); + links.add(CollectionSupport.link("edit", editURI)); + entry.setLinks(links); + return entry; + } + + private Weblog requireWeblog(String[] pathInfo) throws AtomException { + Weblog weblog = CollectionSupport.requireWeblog( + roller, user, pathInfo, WeblogPermission.ADMIN); + if (!isAvailable(user, weblog)) { + // Templates of shared themes are not editable; do not reveal them + throw new AtomNotFoundException("Cannot find template collection"); + } + return weblog; + } + + private WeblogTemplate requireTemplate(Weblog weblog, String[] pathInfo) + throws AtomException, WebloggerException { + WeblogManager mgr = roller.getWeblogManager(); + WeblogTemplate template = pathInfo.length > 2 ? mgr.getTemplate(weblog, pathInfo[2]) : null; + if (template == null) { + throw new AtomNotFoundException("Cannot find template"); + } + return template; + } + + private static ComponentType parseAction(String name) throws AtomException { + try { + return ComponentType.valueOf(name.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw CollectionSupport.badRequest("Unknown template action: " + name); + } + } + + /** 409 when another template is in the way, otherwise 400, with the editor's message. */ + private static AtomException ruleError(Weblog weblog, TemplateRuleException e) { + I18nMessages messages = I18nMessages.getMessages(weblog.getLocaleInstance()); + StringBuilder text = new StringBuilder(); + for (TemplateRuleException.Violation violation : e.getViolations()) { + if (text.length() > 0) { + text.append(' '); + } + text.append(messages.getString(violation.getMessageKey(), violation.getArgs())); + } + return e.isConflict() ? CollectionSupport.conflict(text.toString()) + : CollectionSupport.badRequest(text.toString()); + } + + private WeblogTemplateEditor editor() { + return new WeblogTemplateEditor(roller); + } + + private String collectionURI(Weblog weblog) { + return atomURL + "/" + weblog.getHandle() + "/templates"; + } +} diff --git a/app/src/main/resources/ApplicationResources.properties b/app/src/main/resources/ApplicationResources.properties index 8128a7b3c3..7d9c6a35d4 100644 --- a/app/src/main/resources/ApplicationResources.properties +++ b/app/src/main/resources/ApplicationResources.properties @@ -1047,6 +1047,7 @@ Template.error.nameSize=Name cannot be more than 255 characters Template.error.linkSize=Link cannot be more than 255 characters Template.error.descriptionSize=Description cannot be more than 255 characters Template.error.actionNull=The Action is a required field +Template.error.actionNotAvailable=A {0} template cannot be added to this weblog # ---------------------------------------------------- Ping Target Admin/Editing diff --git a/app/src/test/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditorTest.java b/app/src/test/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditorTest.java new file mode 100644 index 0000000000..1a176e34b8 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditorTest.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.business.themes; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.roller.weblogger.TestUtils; +import org.apache.roller.weblogger.business.WeblogManager; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.pojos.TemplateRendition.RenditionType; +import org.apache.roller.weblogger.pojos.ThemeTemplate.ComponentType; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogTemplate; +import org.apache.roller.weblogger.pojos.WeblogTheme; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the template rules shared by the template editor pages and the + * AtomPub template collection. + */ +public class WeblogTemplateEditorTest { + + private static final String HANDLE = "templateeditorblog"; + + private User user; + private Weblog weblog; + + @BeforeEach + public void setUp() throws Exception { + TestUtils.setupWeblogger(); + user = TestUtils.setupUser("templateeditoruser"); + weblog = TestUtils.setupWeblog(HANDLE, user); + TestUtils.endSession(true); + } + + @AfterEach + public void tearDown() throws Exception { + TestUtils.teardownWeblog(weblog.getId()); + TestUtils.teardownUser(user.getUserName()); + TestUtils.endSession(true); + } + + @Test + public void sharedThemeMayAddCustomTemplatesAndOneWeblogTemplate() throws Exception { + Set actions = editor().availableActions(managedWeblog()); + assertEquals(Set.of(ComponentType.CUSTOM, ComponentType.WEBLOG), actions); + + editor().create(managedWeblog(), ComponentType.WEBLOG, "main", "x", null); + TestUtils.endSession(true); + assertEquals(Set.of(ComponentType.CUSTOM), editor().availableActions(managedWeblog())); + } + + @Test + public void customThemeMayAddEachActionOnce() throws Exception { + useCustomTheme(); + assertEquals(Set.of(ComponentType.CUSTOM, ComponentType.PERMALINK, ComponentType.SEARCH, + ComponentType.WEBLOG, ComponentType.TAGSINDEX), + editor().availableActions(managedWeblog())); + + editor().create(managedWeblog(), ComponentType.PERMALINK, "permalink", "x", null); + TestUtils.endSession(true); + assertFalse(editor().availableActions(managedWeblog()).contains(ComponentType.PERMALINK)); + + TemplateRuleException taken = assertThrows(TemplateRuleException.class, () -> + editor().create(managedWeblog(), ComponentType.PERMALINK, "permalink2", "x", null)); + assertTrue(taken.isConflict()); + assertEquals("Template.error.actionNotAvailable", + taken.getViolations().get(0).getMessageKey()); + } + + @Test + public void newTemplatesNeedAUniqueNameOfValidLength() throws Exception { + editor().create(managedWeblog(), ComponentType.CUSTOM, "sidebar", "x", null); + TestUtils.endSession(true); + + List duplicate = + editor().validateNew(managedWeblog(), ComponentType.CUSTOM, "sidebar"); + assertEquals("pagesForm.error.alreadyExists", duplicate.get(0).getMessageKey()); + assertTrue(duplicate.get(0).isConflict()); + + List missing = + editor().validateNew(managedWeblog(), null, ""); + assertEquals(List.of("Template.error.nameNull", "Template.error.actionNull"), + missing.stream().map(TemplateRuleException.Violation::getMessageKey).collect(Collectors.toList())); + + List tooLong = + editor().validateNew(managedWeblog(), ComponentType.CUSTOM, "x".repeat(256)); + assertEquals("Template.error.nameSize", tooLong.get(0).getMessageKey()); + } + + @Test + public void createSetsLinkAndRenditions() throws Exception { + WeblogTemplate created = editor().create(managedWeblog(), ComponentType.CUSTOM, + "sidebar", "standard", "mobile"); + TestUtils.endSession(true); + + WeblogTemplate stored = weblogManager().getTemplate(managedWeblog(), created.getId()); + assertEquals("sidebar", stored.getLink()); + assertEquals("standard", stored.getTemplateRendition(RenditionType.STANDARD).getTemplate()); + assertEquals("mobile", stored.getTemplateRendition(RenditionType.MOBILE).getTemplate()); + } + + @Test + public void mobileRenditionCanBeRemovedButStandardCannot() throws Exception { + WeblogTemplate created = editor().create(managedWeblog(), ComponentType.CUSTOM, + "sidebar", "standard", "mobile"); + TestUtils.endSession(true); + + WeblogTemplate stored = weblogManager().getTemplate(managedWeblog(), created.getId()); + editor().removeMobileRendition(stored); + TestUtils.endSession(true); + + stored = weblogManager().getTemplate(managedWeblog(), created.getId()); + assertNull(stored.getTemplateRendition(RenditionType.MOBILE)); + assertNotNull(stored.getTemplateRendition(RenditionType.STANDARD)); + + // removing a missing mobile rendition is a no-op + editor().removeMobileRendition(stored); + + WeblogTemplate reloaded = stored; + assertThrows(org.apache.roller.weblogger.WebloggerException.class, () -> weblogManager() + .removeTemplateRendition(reloaded.getTemplateRendition(RenditionType.STANDARD))); + } + + @Test + public void renameMayNotTakeAnotherTemplatesNameOrLink() throws Exception { + editor().create(managedWeblog(), ComponentType.CUSTOM, "one", "x", null); + WeblogTemplate two = editor().create(managedWeblog(), ComponentType.CUSTOM, "two", "x", null); + TestUtils.endSession(true); + + WeblogTemplate stored = weblogManager().getTemplate(managedWeblog(), two.getId()); + assertEquals(2, editor().validateUpdate(managedWeblog(), stored, "one", "one").size()); + assertTrue(editor().validateUpdate(managedWeblog(), stored, "two", "two").isEmpty()); + } + + @Test + public void requiredTemplatesOfACustomThemeAreKept() throws Exception { + useCustomTheme(); + WeblogTemplate main = editor().create(managedWeblog(), ComponentType.WEBLOG, "main", "x", null); + TestUtils.endSession(true); + + WeblogTemplate stored = weblogManager().getTemplate(managedWeblog(), main.getId()); + assertTrue(stored.isRequired()); + TemplateRuleException refused = assertThrows(TemplateRuleException.class, + () -> editor().remove(managedWeblog(), stored)); + assertEquals("editPages.remove.requiredTemplate", + refused.getViolations().get(0).getMessageKey()); + } + + @Test + public void removeDeletesTheTemplate() throws Exception { + WeblogTemplate created = editor().create(managedWeblog(), ComponentType.CUSTOM, + "sidebar", "x", null); + TestUtils.endSession(true); + + editor().remove(managedWeblog(), weblogManager().getTemplate(managedWeblog(), created.getId())); + TestUtils.endSession(true); + assertNull(weblogManager().getTemplate(managedWeblog(), created.getId())); + assertNotNull(managedWeblog()); + } + + private void useCustomTheme() throws Exception { + Weblog managed = managedWeblog(); + managed.setEditorTheme(WeblogTheme.CUSTOM); + weblogManager().saveWeblog(managed); + TestUtils.endSession(true); + } + + private static WeblogTemplateEditor editor() { + return new WeblogTemplateEditor(WebloggerFactory.getWeblogger()); + } + + private static WeblogManager weblogManager() { + return WebloggerFactory.getWeblogger().getWeblogManager(); + } + + private static Weblog managedWeblog() throws Exception { + return weblogManager().getWeblogByHandle(HANDLE); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapperTest.java b/app/src/test/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapperTest.java new file mode 100644 index 0000000000..490fa88cf4 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapperTest.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.pojos.wrapper; + +import org.apache.roller.weblogger.business.URLStrategy; +import org.apache.roller.weblogger.pojos.WeblogCategory; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; + +class WeblogCategoryWrapperTest { + + @Test + void imageIsGivenToTemplatesOnlyAsAnHttpUrl() { + assertEquals("https://example.com/a.png", image("https://example.com/a.png")); + assertEquals("http://example.com/a.png", image("http://example.com/a.png")); + assertNull(image("javascript:alert(1)")); + assertNull(image("https://example.com/a\" b")); + assertNull(image("/images/a.png")); + assertNull(image(null)); + } + + private static String image(String stored) { + WeblogCategory category = new WeblogCategory(); + category.setImage(stored); + return WeblogCategoryWrapper.wrap(category, mock(URLStrategy.class)).getImage(); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java new file mode 100644 index 0000000000..796f3a1740 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPubDocExamplesTest.java @@ -0,0 +1,487 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamReader; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Keeps the XML examples in the AtomPub guide ({@code docs/atompub/examples}) + * in step with the code. + * + *

    + *
  • Response examples are written by {@link AtomWriter} from the same wire + * model the collections build, indented, and compared with the committed + * files. Run with {@code -Datompub.docs.update=true} to rewrite them.
  • + *
  • Request examples ({@code *-request.xml}) are written by hand; they must + * be accepted by {@link AtomReader}.
  • + *
  • All examples are checked against the RFC 4287 / RFC 5023 grammars, and + * every Roller extension element against {@code docs/atompub/roller-atompub.rnc}.
  • + *
+ */ +public class AtomPubDocExamplesTest { + + private static final String ROLLER = "https://example.com/roller"; + private static final String APP = ROLLER + "/roller-services/app"; + private static final String BLOG = APP + "/myblog"; + private static final Date PUBLISHED = Date.from(Instant.parse("2026-10-10T12:00:00Z")); + private static final Date UPDATED = Date.from(Instant.parse("2026-10-10T12:30:00Z")); + + private static final String ROLLER_SCHEMA = "roller-atompub.rnc"; + + private static final Path DOCS = locateDocs(); + + @Test + public void generatedExamplesMatchTheDocs() throws Exception { + boolean update = Boolean.getBoolean("atompub.docs.update"); + List stale = new ArrayList<>(); + for (Map.Entry example : generatedExamples().entrySet()) { + String expected = indent(example.getValue()); + Path file = DOCS.resolve("examples").resolve(example.getKey()); + if (update) { + Files.write(file, expected.getBytes(StandardCharsets.UTF_8)); + } else if (!Files.exists(file) + || !expected.equals(new String(Files.readAllBytes(file), StandardCharsets.UTF_8))) { + stale.add(example.getKey()); + } + } + assertTrue(stale.isEmpty(), "AtomPub doc examples are out of date: " + stale + + ". Regenerate with: mvn -pl app test -Dtest=AtomPubDocExamplesTest" + + " -Datompub.docs.update=true"); + } + + @Test + public void requestExamplesAreAcceptedByTheServer() throws Exception { + AtomEntry entry = read("entry-request.xml"); + assertEquals("Hello AtomPub", entry.getTitle()); + assertEquals(2, entry.getCategories().size()); + + AtomEntry category = read("category-request.xml"); + assertEquals("Travel", category.getTitle()); + assertEquals("https://example.com/images/travel.png", category.getExtension("image")); + + AtomEntry template = read("template-request.xml"); + assertEquals("custom", template.getExtension("action")); + assertTrue(template.getMobileRendition().contains("sidebar-mobile")); + + AtomEntry status = read("comment-status-request.xml"); + assertEquals("approved", status.getExtension("status")); + } + + @Test + public void examplesConformToTheSchemas() throws Exception { + try (Stream files = Files.list(DOCS.resolve("examples"))) { + for (Path file : (Iterable) files.filter(f -> f.toString().endsWith(".xml"))::iterator) { + byte[] xml = Files.readAllBytes(file); + String root = parse(xml).name; + String schema = root.endsWith("service") ? "/atompub/app-service.rnc" + : root.endsWith("categories") ? null : "/atompub/atom.rnc"; + if (schema != null) { + List errors = RelaxNgValidator.validateResource(schema, xml); + assertTrue(errors.isEmpty(), file.getFileName() + ": " + errors); + } + for (byte[] extension : extensionElements(parse(xml))) { + List errors; + try (InputStream rnc = Files.newInputStream(DOCS.resolve(ROLLER_SCHEMA))) { + errors = RelaxNgValidator.validate(rnc, ROLLER_SCHEMA, extension); + } + assertTrue(errors.isEmpty(), file.getFileName() + ": " + + new String(extension, StandardCharsets.UTF_8) + " " + errors); + } + } + } + } + + // ------------------------------------------------------------ examples + + private static Map generatedExamples() throws Exception { + Map examples = new LinkedHashMap<>(); + examples.put("service.xml", write(w -> w.writeServiceDoc(w.out, serviceDoc()))); + examples.put("entry-response.xml", write(w -> w.writeEntry(w.out, weblogEntry()))); + examples.put("entries-feed.xml", write(w -> w.writeFeed(w.out, entriesFeed()))); + examples.put("media-link-entry.xml", write(w -> w.writeEntry(w.out, mediaLinkEntry()))); + examples.put("categories.xml", write(w -> w.writeCategoriesDoc(w.out, categories()))); + examples.put("category-entry.xml", write(w -> w.writeEntry(w.out, categoryEntry()))); + examples.put("template-entry.xml", write(w -> w.writeEntry(w.out, templateEntry()))); + examples.put("comment-entry.xml", write(w -> w.writeEntry(w.out, commentEntry()))); + examples.put("comments-feed.xml", write(w -> w.writeFeed(w.out, commentsFeed()))); + return examples; + } + + private static AtomServiceDoc serviceDoc() { + AtomServiceDoc service = new AtomServiceDoc(); + AtomWorkspace workspace = new AtomWorkspace(); + workspace.setTitle("My Weblog"); + service.getWorkspaces().add(workspace); + + AtomCollection entries = collection("Weblog Entries", BLOG + "/entries", + "application/atom+xml;type=entry"); + AtomCategories inline = categories(); + entries.getCategories().add(inline); + AtomCategories byReference = new AtomCategories(); + byReference.setHref(BLOG + "/categories.atomcat"); + entries.getCategories().add(byReference); + entries.getCategories().add(new AtomCategories()); + workspace.getCollections().add(entries); + + AtomCollection media = collection("Media Files: default", BLOG + "/resources/default", + "image/*"); + media.getAccepts().add("application/pdf"); + workspace.getCollections().add(media); + workspace.getCollections().add(collection("Templates", BLOG + "/templates", + "application/atom+xml;type=entry")); + workspace.getCollections().add(collection("Categories", BLOG + "/categories", + "application/atom+xml;type=entry")); + workspace.getCollections().add(collection("Comments", BLOG + "/comments", "")); + return service; + } + + private static AtomEntry weblogEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId(ROLLER + "/myblog/entry/hello_atompub"); + entry.setTitle("Hello AtomPub"); + entry.setPublished(PUBLISHED); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + entry.setContent(CollectionSupport.text("html", "

Posted with AtomPub.

")); + AtomPerson author = new AtomPerson(); + author.setName("dave"); + author.setEmail("dave@example.com"); + entry.getAuthors().add(author); + AtomCategory category = new AtomCategory(); + category.setTerm("Java"); + category.setScheme(ROLLER + "/myblog/"); + entry.getCategories().add(category); + AtomCategory tag = new AtomCategory(); + tag.setTerm("atompub"); + entry.getCategories().add(tag); + entry.getLinks().add(new AtomLink("alternate", ROLLER + "/myblog/entry/hello_atompub")); + entry.getLinks().add(new AtomLink("edit", BLOG + "/entry/4a7d1ed4")); + return entry; + } + + private static AtomFeed entriesFeed() { + AtomFeed feed = new AtomFeed(); + feed.setId(BLOG + "/entries/0"); + feed.setTitle("My Weblog"); + feed.setUpdated(UPDATED); + feed.getLinks().add(link("alternate", ROLLER + "/myblog")); + feed.getLinks().add(link("next", BLOG + "/entries/20")); + feed.getEntries().add(weblogEntry()); + return feed; + } + + private static AtomEntry mediaLinkEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId(BLOG + "/resource/default/sunset.jpg"); + entry.setTitle("sunset.jpg"); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + AtomContent content = new AtomContent(); + content.setType("image/jpeg"); + content.setSrc(ROLLER + "/myblog/mediaresource/2b9c4f0e"); + entry.setContent(content); + entry.getLinks().add(link("alternate", ROLLER + "/myblog/mediaresource/2b9c4f0e")); + entry.getLinks().add(link("edit", BLOG + "/resource/default/sunset.jpg.media-link")); + entry.getLinks().add(link("edit-media", BLOG + "/resource/default/sunset.jpg")); + return entry; + } + + private static AtomCategories categories() { + AtomCategories cats = new AtomCategories(); + cats.setFixed(true); + cats.setScheme(ROLLER + "/myblog/"); + for (String name : Arrays.asList("General", "Java", "Travel")) { + AtomCategory cat = new AtomCategory(); + cat.setTerm(name); + cat.setLabel(name); + cats.getCategories().add(cat); + } + return cats; + } + + private static AtomEntry categoryEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId(BLOG + "/category/7c3e9a10"); + entry.setTitle("Travel"); + entry.setUpdated(UPDATED); + entry.setSummary(CollectionSupport.text("text", "Trips and places")); + entry.setExtension("image", "https://example.com/images/travel.png"); + entry.setExtension("position", "2"); + entry.setExtension("inUse", "true"); + entry.getLinks().add(link("edit", BLOG + "/category/7c3e9a10")); + return entry; + } + + private static AtomEntry templateEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId(BLOG + "/template/0e5d8b62"); + entry.setTitle("sidebar"); + entry.setSummary(CollectionSupport.text("text", "The sidebar")); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + entry.setContent(CollectionSupport.text("text", + "
$model.weblog.name
")); + entry.setMobileRendition("
$model.weblog.name
"); + entry.setExtension("action", "custom"); + entry.setExtension("link", "sidebar"); + entry.setExtension("navbar", "true"); + entry.setExtension("hidden", "false"); + entry.setExtension("required", "false"); + entry.getLinks().add(link("edit", BLOG + "/template/0e5d8b62")); + return entry; + } + + private static AtomEntry commentEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId(BLOG + "/comment/8f0e2a6c"); + entry.setTitle("Comment on Hello AtomPub"); + entry.setPublished(PUBLISHED); + entry.setUpdated(PUBLISHED); + AtomPerson author = new AtomPerson(); + author.setName("Pat Reader"); + author.setEmail("pat@example.org"); + author.setUri("https://pat.example.org/"); + entry.getAuthors().add(author); + entry.setContent(CollectionSupport.text("text", "Nice post!")); + entry.setInReplyToRef(ROLLER + "/myblog/entry/hello_atompub"); + entry.setInReplyToHref(BLOG + "/entry/4a7d1ed4"); + entry.setExtension("status", "pending"); + entry.setExtension("remoteHost", "192.0.2.10"); + entry.getLinks().add(link("edit", BLOG + "/comment/8f0e2a6c")); + entry.getLinks().add(link("related", ROLLER + "/myblog/entry/hello_atompub")); + return entry; + } + + private static AtomFeed commentsFeed() { + AtomFeed feed = new AtomFeed(); + feed.setId(BLOG + "/comments/0"); + feed.setTitle("My Weblog comments"); + feed.setUpdated(UPDATED); + feed.getLinks().add(link("next", BLOG + "/comments/20?status=pending")); + feed.getEntries().add(commentEntry()); + return feed; + } + + private static AtomCollection collection(String title, String href, String accept) { + AtomCollection collection = new AtomCollection(); + collection.setTitle(title); + collection.setHref(href); + collection.getAccepts().add(accept); + return collection; + } + + private static AtomLink link(String rel, String href) { + return CollectionSupport.link(rel, href); + } + + // ------------------------------------------------------------- helpers + + @FunctionalInterface + private interface Writing { + void run(WriterOut w) throws AtomException; + } + + /** An AtomWriter with the stream it writes to. */ + private static final class WriterOut extends AtomWriter { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + } + + private static byte[] write(Writing writing) throws AtomException { + WriterOut w = new WriterOut(); + writing.run(w); + return w.out.toByteArray(); + } + + private static AtomEntry read(String name) throws IOException, AtomException { + try (InputStream in = Files.newInputStream(DOCS.resolve("examples").resolve(name))) { + return new AtomReader().parseEntry(in); + } + } + + /** docs/atompub, from the app module (Maven) or the repository root (IDE). */ + private static Path locateDocs() { + Path cwd = Paths.get("").toAbsolutePath(); + for (Path base : Arrays.asList(cwd.getParent(), cwd)) { + if (base != null && Files.isDirectory(base.resolve("docs/atompub"))) { + return base.resolve("docs/atompub"); + } + } + throw new IllegalStateException("Cannot find docs/atompub from " + cwd); + } + + // ----------------------------------------- a small XML tree and printer + + /** An element: qualified name, attributes and namespace declarations, children. */ + private static final class Node { + final String name; + final List attributes = new ArrayList<>(); + final List children = new ArrayList<>(); + String namespace; + + Node(String name) { + this.name = name; + } + } + + private static Node parse(byte[] xml) throws Exception { + XMLInputFactory factory = XMLInputFactory.newInstance(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + XMLStreamReader r = factory.createXMLStreamReader(new ByteArrayInputStream(xml), "UTF-8"); + List stack = new ArrayList<>(); + Node root = null; + while (r.hasNext()) { + int event = r.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + Node node = new Node(qname(r.getPrefix(), r.getLocalName())); + node.namespace = r.getNamespaceURI(); + for (int i = 0; i < r.getNamespaceCount(); i++) { + String prefix = r.getNamespacePrefix(i); + node.attributes.add(new String[] { + prefix == null || prefix.isEmpty() ? "xmlns" : "xmlns:" + prefix, + r.getNamespaceURI(i)}); + } + for (int i = 0; i < r.getAttributeCount(); i++) { + node.attributes.add(new String[] { + qname(r.getAttributePrefix(i), r.getAttributeLocalName(i)), + r.getAttributeValue(i)}); + } + if (stack.isEmpty()) { + root = node; + } else { + stack.get(stack.size() - 1).children.add(node); + } + stack.add(node); + } else if (event == XMLStreamConstants.END_ELEMENT) { + stack.remove(stack.size() - 1); + } else if ((event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA) + && !stack.isEmpty() && !r.isWhiteSpace()) { + stack.get(stack.size() - 1).children.add(r.getText()); + } + } + return root; + } + + private static String qname(String prefix, String local) { + return prefix == null || prefix.isEmpty() ? local : prefix + ":" + local; + } + + /** Re-indents a document: two spaces per level, text-only elements on one line. */ + private static String indent(byte[] xml) throws Exception { + StringBuilder out = new StringBuilder("\n"); + print(parse(xml), 0, out); + return out.toString(); + } + + private static void print(Node node, int depth, StringBuilder out) { + String pad = " ".repeat(depth); + out.append(pad).append('<').append(node.name); + for (String[] attribute : node.attributes) { + out.append(' ').append(attribute[0]).append("=\"") + .append(escape(attribute[1], true)).append('"'); + } + if (node.children.isEmpty()) { + out.append("/>\n"); + return; + } + boolean textOnly = node.children.stream().allMatch(c -> c instanceof String); + if (textOnly) { + out.append('>'); + for (Object text : node.children) { + out.append(escape((String) text, false)); + } + out.append("\n"); + return; + } + out.append(">\n"); + for (Object child : node.children) { + if (child instanceof Node) { + print((Node) child, depth + 1, out); + } else { + out.append(pad).append(" ").append(escape((String) child, false)).append('\n'); + } + } + out.append(pad).append("\n"); + } + + private static String escape(String text, boolean attribute) { + String escaped = text.replace("&", "&").replace("<", "<").replace(">", ">"); + return attribute ? escaped.replace("\"", """) : escaped; + } + + /** Each roller:* or thr:* element, serialized alone with its namespace declared. */ + private static List extensionElements(Node node) { + List found = new ArrayList<>(); + collectExtensions(node, found); + return found; + } + + private static void collectExtensions(Node node, List found) { + for (Object child : node.children) { + if (!(child instanceof Node)) { + continue; + } + Node element = (Node) child; + if (AtomConstants.ROLLER_NS.equals(element.namespace) + || AtomConstants.THREAD_NS.equals(element.namespace)) { + StringBuilder xml = new StringBuilder(); + String prefix = element.name.substring(0, element.name.indexOf(':')); + xml.append('<').append(element.name).append(" xmlns:").append(prefix) + .append("=\"").append(element.namespace).append('"'); + for (String[] attribute : element.attributes) { + if (!attribute[0].startsWith("xmlns")) { + xml.append(' ').append(attribute[0]).append("=\"") + .append(escape(attribute[1], true)).append('"'); + } + } + xml.append('>'); + for (Object text : element.children) { + xml.append(escape(String.valueOf(text), false)); + } + xml.append("'); + found.add(xml.toString().getBytes(StandardCharsets.UTF_8)); + } else { + collectExtensions(element, found); + } + } + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java index e5f4ce6a04..12e888afa9 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java @@ -178,4 +178,42 @@ public void testXhtmlContentAndTitle() throws Exception { assertEquals("xhtml", entry.getContent().getType()); assertEquals("

One & two
three

", entry.getContent().getValue()); } + + @Test + public void testRollerExtensionsAndInReplyTo() throws Exception { + String xml = + "" + + "sidebar" + + "<div>standard</div>" + + "<div>mobile</div>" + + "custom" + + "true" + + "" + + ""; + AtomEntry entry = parse(xml); + assertEquals("
standard
", entry.getContent().getValue()); + assertEquals("
mobile
", entry.getMobileRendition()); + assertEquals("custom", entry.getExtension("action")); + assertEquals("true", entry.getExtension("navbar")); + assertEquals("urn:parent", entry.getInReplyToRef()); + assertEquals("http://example.com/app/blog/entry/1", entry.getInReplyToHref()); + } + + @Test + public void testExtensionRoundTrip() throws Exception { + AtomEntry out = new AtomEntry(); + out.setTitle("t"); + out.setMobileRendition("

m & n

"); + out.setExtension("status", "spam"); + out.setInReplyToRef("urn:x"); + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + new AtomWriter().writeEntry(bytes, out); + + AtomEntry in = new AtomReader().parseEntry(new ByteArrayInputStream(bytes.toByteArray())); + assertEquals("

m & n

", in.getMobileRendition()); + assertEquals("spam", in.getExtension("status")); + assertEquals("urn:x", in.getInReplyToRef()); + } } diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java index f5d6fdc265..c51f3cdde8 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java @@ -19,24 +19,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.InputStream; import java.time.Instant; -import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; import org.junit.jupiter.api.Test; -import org.xml.sax.ErrorHandler; -import org.xml.sax.InputSource; -import org.xml.sax.SAXParseException; -import com.thaiopensource.util.PropertyMapBuilder; -import com.thaiopensource.validate.ValidateProperty; -import com.thaiopensource.validate.ValidationDriver; -import com.thaiopensource.validate.rng.CompactSchemaReader; /** * Validates the XML produced by {@link AtomWriter} against the official RELAX NG @@ -49,25 +39,8 @@ public class AtomSchemaValidationTest { private static final Date PUBLISHED = Date.from(Instant.parse("2026-06-03T12:34:56Z")); private static final Date UPDATED = Date.from(Instant.parse("2026-06-04T01:02:03Z")); - /** Validate xml against a classpath RELAX NG Compact schema; return errors (empty == valid). */ private List validate(String schemaResource, byte[] xml) throws Exception { - List errors = new ArrayList<>(); - ErrorHandler handler = new ErrorHandler() { - @Override public void warning(SAXParseException e) { /* ignore warnings */ } - @Override public void error(SAXParseException e) { errors.add(e.getMessage()); } - @Override public void fatalError(SAXParseException e) { errors.add(e.getMessage()); } - }; - PropertyMapBuilder props = new PropertyMapBuilder(); - props.put(ValidateProperty.ERROR_HANDLER, handler); - ValidationDriver driver = - new ValidationDriver(props.toPropertyMap(), CompactSchemaReader.getInstance()); - - try (InputStream schema = getClass().getResourceAsStream(schemaResource)) { - assertTrue(driver.loadSchema(new InputSource(schema)), - "schema " + schemaResource + " failed to compile: " + errors); - } - driver.validate(new InputSource(new ByteArrayInputStream(xml))); - return errors; + return RelaxNgValidator.validateResource(schemaResource, xml); } private byte[] writeEntry(AtomEntry entry) throws Exception { @@ -195,4 +168,84 @@ public void testServiceDocConformsToAppSchema() throws Exception { List errors = validate("/atompub/app-service.rnc", out.toByteArray()); assertTrue(errors.isEmpty(), "service document should be schema-valid but: " + errors); } + + @Test + public void testTemplateEntryWithExtensionsConformsToAtomSchema() throws Exception { + AtomEntry entry = new AtomEntry(); + entry.setId("http://example.com/app/blog/template/1"); + entry.setTitle("sidebar"); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + AtomContent content = new AtomContent(); + content.setType("text"); + content.setValue("
$model.weblog.name
"); + entry.setContent(content); + entry.setMobileRendition("
mobile
"); + entry.setExtension("action", "custom"); + entry.setExtension("navbar", "false"); + entry.getLinks().add(new AtomLink("edit", "http://example.com/app/blog/template/1")); + + List errors = validate("/atompub/atom.rnc", writeEntry(entry)); + assertTrue(errors.isEmpty(), "template entry should be schema-valid but: " + errors); + } + + @Test + public void testCommentEntryConformsToAtomSchema() throws Exception { + AtomEntry entry = new AtomEntry(); + entry.setId("http://example.com/app/blog/comment/1"); + entry.setTitle("Comment on Hello"); + entry.setPublished(PUBLISHED); + entry.setUpdated(PUBLISHED); + AtomPerson author = new AtomPerson(); + author.setName("bob"); + author.setUri("http://bob.example.com/"); + author.setEmail("bob@example.com"); + entry.getAuthors().add(author); + AtomContent content = new AtomContent(); + content.setType("text"); + content.setValue("Nice post"); + entry.setContent(content); + entry.setInReplyToRef("http://example.com/blog/entry/hello"); + entry.setInReplyToHref("http://example.com/app/blog/entry/42"); + entry.setExtension("status", "pending"); + entry.getLinks().add(new AtomLink("edit", "http://example.com/app/blog/comment/1")); + + List errors = validate("/atompub/atom.rnc", writeEntry(entry)); + assertTrue(errors.isEmpty(), "comment entry should be schema-valid but: " + errors); + } + + @Test + public void testOutOfLineCategoriesAndReadOnlyCollectionConformToAppSchema() throws Exception { + AtomServiceDoc service = new AtomServiceDoc(); + AtomWorkspace workspace = new AtomWorkspace(); + workspace.setTitle("My Weblog"); + service.getWorkspaces().add(workspace); + + AtomCollection entries = new AtomCollection(); + entries.setTitle("Weblog Entries"); + entries.setHref("http://example.com/app/blog/entries"); + entries.setAccepts(Arrays.asList("application/atom+xml;type=entry")); + AtomCategories inline = new AtomCategories(); + inline.setFixed(true); + AtomCategory cat = new AtomCategory(); + cat.setTerm("tech"); + inline.getCategories().add(cat); + entries.getCategories().add(inline); + AtomCategories byReference = new AtomCategories(); + byReference.setHref("http://example.com/app/blog/categories.atomcat"); + entries.getCategories().add(byReference); + workspace.getCollections().add(entries); + + AtomCollection comments = new AtomCollection(); + comments.setTitle("Comments"); + comments.setHref("http://example.com/app/blog/comments"); + comments.setAccepts(Arrays.asList("")); + workspace.getCollections().add(comments); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeServiceDoc(out, service); + + List errors = validate("/atompub/app-service.rnc", out.toByteArray()); + assertTrue(errors.isEmpty(), "service document should be schema-valid but: " + errors); + } } diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java index 36870dd8ec..9f39933b54 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java @@ -254,4 +254,23 @@ public void testEntryRoundTripThroughReader() throws Exception { assertEquals("http://example.com/cats", parsed.getCategories().get(0).getScheme()); assertNull(parsed.getCategories().get(1).getScheme()); } + + @Test + public void testWriteCategoriesDocument() throws Exception { + AtomCategories cats = new AtomCategories(); + cats.setFixed(true); + cats.setScheme("http://example.com/blog/"); + AtomCategory cat = new AtomCategory(); + cat.setTerm("Travel"); + cat.setLabel("Travel"); + cats.getCategories().add(cat); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeCategoriesDoc(out, cats); + + Document doc = parse(out.toByteArray()); + assertEquals("categories", doc.getDocumentElement().getLocalName()); + assertEquals(APP_NS, doc.getDocumentElement().getNamespaceURI()); + assertEquals("yes", doc.getDocumentElement().getAttribute("fixed")); + assertEquals("Travel", element(doc, ATOM_NS, "category").getAttribute("term")); + } } diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java new file mode 100644 index 0000000000..4d8968ef07 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RelaxNgValidator.java @@ -0,0 +1,72 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. 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. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.xml.sax.ErrorHandler; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; + +import com.thaiopensource.util.PropertyMapBuilder; +import com.thaiopensource.validate.ValidateProperty; +import com.thaiopensource.validate.ValidationDriver; +import com.thaiopensource.validate.rng.CompactSchemaReader; + +/** Validates XML against a RELAX NG Compact schema with Jing. */ +final class RelaxNgValidator { + + private RelaxNgValidator() { + } + + /** + * Validates {@code xml} against the schema in {@code schema}; returns the + * errors, empty when the document is valid. + */ + static List validate(InputStream schema, String schemaName, byte[] xml) + throws IOException, SAXException { + List errors = new ArrayList<>(); + ErrorHandler handler = new ErrorHandler() { + @Override public void warning(SAXParseException e) { /* ignore warnings */ } + @Override public void error(SAXParseException e) { errors.add(e.getMessage()); } + @Override public void fatalError(SAXParseException e) { errors.add(e.getMessage()); } + }; + PropertyMapBuilder props = new PropertyMapBuilder(); + props.put(ValidateProperty.ERROR_HANDLER, handler); + ValidationDriver driver = + new ValidationDriver(props.toPropertyMap(), CompactSchemaReader.getInstance()); + if (!driver.loadSchema(new InputSource(schema))) { + throw new IllegalStateException("schema " + schemaName + " failed to compile: " + errors); + } + driver.validate(new InputSource(new ByteArrayInputStream(xml))); + return errors; + } + + /** Validates against a classpath schema. */ + static List validateResource(String schemaResource, byte[] xml) + throws IOException, SAXException { + try (InputStream schema = RelaxNgValidator.class.getResourceAsStream(schemaResource)) { + return validate(schema, schemaResource, xml); + } + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerTest.java index 62fda8edea..220110fc64 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerTest.java @@ -38,7 +38,11 @@ import java.util.Base64; import org.springframework.security.crypto.password.PasswordEncoder; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -146,4 +150,48 @@ void nullAuthenticationMethodIsRejected() { verify(request, never()).getHeader("Authorization"); } } + + @Test + void managementUrisAreClassified() { + try (MockedStatic factory = mockStatic(WebloggerFactory.class); + MockedStatic config = mockStatic(WebloggerRuntimeConfig.class)) { + factory.when(WebloggerFactory::getWeblogger).thenReturn(weblogger); + RollerAtomHandler handler = new RollerAtomHandler(request, response); + + for (String collection : new String[] {"templates", "categories", "comments"}) { + assertTrue(handler.isCollectionURI(atomRequest("/blog/" + collection)), collection); + } + for (String member : new String[] {"template", "category", "comment"}) { + assertTrue(RollerAtomHandler.isEntryPath("/blog/" + member + "/abc"), member); + assertFalse(RollerAtomHandler.isEntryPath("/blog/" + member), member); + } + assertTrue(handler.isCategoriesDocURI(atomRequest("/blog/categories.atomcat"))); + assertFalse(handler.isCategoriesDocURI(atomRequest("/blog/categories"))); + assertFalse(handler.isCollectionURI(atomRequest("/blog/categories.atomcat"))); + } + } + + @Test + void entryOnlyCollectionsRefuseMediaAndCommentsRefusePost() { + try (MockedStatic factory = mockStatic(WebloggerFactory.class); + MockedStatic config = mockStatic(WebloggerRuntimeConfig.class)) { + factory.when(WebloggerFactory::getWeblogger).thenReturn(weblogger); + RollerAtomHandler handler = new RollerAtomHandler(request, response); + + for (String collection : new String[] {"templates", "categories", "comments"}) { + AtomException refused = assertThrows(AtomException.class, () -> + handler.postMedia(atomRequest("/blog/" + collection), new AtomEntry())); + assertEquals(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, refused.getStatus()); + } + AtomException post = assertThrows(AtomException.class, () -> + handler.postEntry(atomRequest("/blog/comments"), new AtomEntry())); + assertEquals(HttpServletResponse.SC_METHOD_NOT_ALLOWED, post.getStatus()); + } + } + + private static AtomRequest atomRequest(String pathInfo) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getPathInfo()).thenReturn(pathInfo); + return new AtomRequest(req, null); + } } diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomManagementTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomManagementTest.java new file mode 100644 index 0000000000..4387a68a98 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomManagementTest.java @@ -0,0 +1,520 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.List; +import java.util.stream.Collectors; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.roller.weblogger.TestUtils; +import org.apache.roller.weblogger.business.PropertiesManager; +import org.apache.roller.weblogger.business.WeblogEntryManager; +import org.apache.roller.weblogger.business.WeblogManager; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.pojos.CustomTemplateRendition; +import org.apache.roller.weblogger.pojos.RuntimeConfigProperty; +import org.apache.roller.weblogger.pojos.TemplateRendition.RenditionType; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogCategory; +import org.apache.roller.weblogger.pojos.WeblogEntry; +import org.apache.roller.weblogger.pojos.WeblogEntryComment; +import org.apache.roller.weblogger.pojos.WeblogEntryComment.ApprovalStatus; +import org.apache.roller.weblogger.pojos.WeblogPermission; +import org.apache.roller.weblogger.pojos.WeblogTemplate; +import org.apache.roller.weblogger.pojos.WeblogTheme; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +/** + * Integration tests for the AtomPub template, category and comment + * collections against the in-memory test database. + */ +public class RollerAtomManagementTest { + + private static final String HANDLE = "atommgmtblog"; + private static final String OTHER_HANDLE = "atommgmtother"; + private static final String ATOM_URL = "http://localhost/roller/roller-services/app"; + + private User owner; + private User author; + private Weblog weblog; + private Weblog otherWeblog; + + @BeforeEach + public void setUp() throws Exception { + TestUtils.setupWeblogger(); + owner = TestUtils.setupUser("atommgmtowner"); + author = TestUtils.setupUser("atommgmtauthor"); + weblog = TestUtils.setupWeblog(HANDLE, owner); + otherWeblog = TestUtils.setupWeblog(OTHER_HANDLE, owner); + WebloggerFactory.getWeblogger().getUserManager().grantWeblogPermission( + weblog, author, List.of(WeblogPermission.POST)); + setProperty("webservices.enableAtomPub", "true"); + setProperty("themes.customtheme.allowed", "true"); + TestUtils.endSession(true); + } + + @AfterEach + public void tearDown() throws Exception { + TestUtils.teardownWeblog(weblog.getId()); + TestUtils.teardownWeblog(otherWeblog.getId()); + TestUtils.teardownUser(author.getUserName()); + TestUtils.teardownUser(owner.getUserName()); + TestUtils.endSession(true); + } + + // ------------------------------------------------------------ templates + + @Test + public void templatesOfASharedThemeAreNotFound() throws Exception { + assertThrows(AtomNotFoundException.class, () -> templates(owner) + .getCollection(request("/" + HANDLE + "/templates"))); + } + + @Test + public void templatesNeedTheAdminPermission() throws Exception { + useCustomTheme(); + assertThrows(AtomNotAuthorizedException.class, () -> templates(author) + .getCollection(request("/" + HANDLE + "/templates"))); + } + + @Test + public void templatesNeedCustomThemesToBeAllowed() throws Exception { + useCustomTheme(); + setProperty("themes.customtheme.allowed", "false"); + TestUtils.endSession(true); + assertThrows(AtomNotFoundException.class, () -> templates(owner) + .getCollection(request("/" + HANDLE + "/templates"))); + } + + @Test + public void templateLifecycle() throws Exception { + useCustomTheme(); + + // create a custom template with both renditions + AtomEntry in = new AtomEntry(); + in.setTitle("sidebar"); + in.setSummary(CollectionSupport.text("text", "The sidebar")); + in.setContent(CollectionSupport.text("text", "
standard
")); + in.setMobileRendition("
mobile
"); + in.setExtension("navbar", "true"); + AtomEntry created = templates(owner).postEntry(request("/" + HANDLE + "/templates"), in); + TestUtils.endSession(true); + + String id = idFrom(created, "/template/"); + assertEquals("custom", created.getExtension("action")); + assertEquals("sidebar", created.getExtension("link")); + assertEquals("true", created.getExtension("navbar")); + assertEquals("
mobile
", created.getMobileRendition()); + + // read it back + AtomEntry read = templates(owner).getEntry(request("/" + HANDLE + "/template/" + id)); + assertEquals("sidebar", read.getTitle()); + assertEquals("The sidebar", read.getSummary().getValue()); + assertEquals("
standard
", read.getContent().getValue()); + + // the feed lists it + AtomFeed feed = templates(owner).getCollection(request("/" + HANDLE + "/templates")); + assertTrue(feed.getEntries().stream().anyMatch(e -> "sidebar".equals(e.getTitle()))); + + // update both renditions; leave the metadata alone + AtomEntry update = new AtomEntry(); + update.setContent(CollectionSupport.text("text", "
standard 2
")); + update.setMobileRendition("
mobile 2
"); + templates(owner).putEntry(request("/" + HANDLE + "/template/" + id), update); + TestUtils.endSession(true); + + WeblogTemplate stored = weblogManager().getTemplate(managedWeblog(), id); + assertEquals("sidebar", stored.getName()); + assertEquals("
standard 2
", + stored.getTemplateRendition(RenditionType.STANDARD).getTemplate()); + CustomTemplateRendition mobile = stored.getTemplateRendition(RenditionType.MOBILE); + assertEquals("
mobile 2
", mobile.getTemplate()); + + // an empty mobile rendition removes it; the standard one stays + AtomEntry dropMobile = new AtomEntry(); + dropMobile.setMobileRendition(""); + templates(owner).putEntry(request("/" + HANDLE + "/template/" + id), dropMobile); + TestUtils.endSession(true); + stored = weblogManager().getTemplate(managedWeblog(), id); + assertNull(stored.getTemplateRendition(RenditionType.MOBILE)); + assertEquals("
standard 2
", + stored.getTemplateRendition(RenditionType.STANDARD).getTemplate()); + assertNull(templates(owner).getEntry(request("/" + HANDLE + "/template/" + id)) + .getMobileRendition()); + + // a second template with the same name conflicts + AtomEntry duplicate = new AtomEntry(); + duplicate.setTitle("sidebar"); + AtomException conflict = assertThrows(AtomException.class, () -> + templates(owner).postEntry(request("/" + HANDLE + "/templates"), duplicate)); + assertEquals(HttpServletResponse.SC_CONFLICT, conflict.getStatus()); + TestUtils.endSession(false); + + // delete it + templates(owner).deleteEntry(request("/" + HANDLE + "/template/" + id)); + TestUtils.endSession(true); + assertNull(weblogManager().getTemplate(managedWeblog(), id)); + } + + @Test + public void requiredTemplatesOfACustomThemeCannotBeRemoved() throws Exception { + useCustomTheme(); + AtomEntry in = new AtomEntry(); + in.setTitle("main"); + in.setExtension("action", "weblog"); + in.setContent(CollectionSupport.text("text", "main page")); + AtomEntry created = templates(owner).postEntry(request("/" + HANDLE + "/templates"), in); + TestUtils.endSession(true); + assertEquals("Weblog", created.getTitle()); + assertEquals("true", created.getExtension("required")); + + String id = idFrom(created, "/template/"); + AtomException refused = assertThrows(AtomException.class, () -> + templates(owner).deleteEntry(request("/" + HANDLE + "/template/" + id))); + assertEquals(HttpServletResponse.SC_CONFLICT, refused.getStatus()); + } + + @Test + public void templatesOfAnotherWeblogAreNotFound() throws Exception { + useCustomTheme(); + AtomEntry in = new AtomEntry(); + in.setTitle("mine"); + AtomEntry created = templates(owner).postEntry(request("/" + HANDLE + "/templates"), in); + TestUtils.endSession(true); + String id = idFrom(created, "/template/"); + + Weblog other = WebloggerFactory.getWeblogger().getWeblogManager() + .getWeblogByHandle(OTHER_HANDLE); + other.setEditorTheme(WeblogTheme.CUSTOM); + weblogManager().saveWeblog(other); + TestUtils.endSession(true); + + assertThrows(AtomNotFoundException.class, () -> templates(owner) + .getEntry(request("/" + OTHER_HANDLE + "/template/" + id))); + } + + // ----------------------------------------------------------- categories + + @Test + public void categoryLifecycle() throws Exception { + AtomEntry in = new AtomEntry(); + in.setTitle("Travel"); + in.setSummary(CollectionSupport.text("text", "Trips")); + AtomEntry created = categories(author).postEntry(request("/" + HANDLE + "/categories"), in); + TestUtils.endSession(true); + String id = idFrom(created, "/category/"); + assertEquals("false", created.getExtension("inUse")); + + // duplicate name conflicts + AtomEntry duplicate = new AtomEntry(); + duplicate.setTitle("Travel"); + AtomException conflict = assertThrows(AtomException.class, () -> categories(author) + .postEntry(request("/" + HANDLE + "/categories"), duplicate)); + assertEquals(HttpServletResponse.SC_CONFLICT, conflict.getStatus()); + TestUtils.endSession(false); + + // markup in a name is refused + AtomEntry markup = new AtomEntry(); + markup.setTitle("bold"); + AtomException invalid = assertThrows(AtomException.class, () -> categories(author) + .postEntry(request("/" + HANDLE + "/categories"), markup)); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, invalid.getStatus()); + TestUtils.endSession(false); + + // rename + AtomEntry rename = new AtomEntry(); + rename.setTitle("Journeys"); + categories(author).putEntry(request("/" + HANDLE + "/category/" + id), rename); + TestUtils.endSession(true); + assertEquals("Journeys", entryManager().getWeblogCategory(managedWeblog(), id).getName()); + + // the category document lists it + AtomCategories doc = categories(author) + .getCategoriesDocument(request("/" + HANDLE + "/categories.atomcat")); + assertTrue(doc.isFixed()); + assertTrue(doc.getCategories().stream().anyMatch(c -> "Journeys".equals(c.getTerm()))); + + // delete + categories(author).deleteEntry(request("/" + HANDLE + "/category/" + id)); + TestUtils.endSession(true); + assertNull(entryManager().getWeblogCategory(managedWeblog(), id)); + } + + @Test + public void categoryInUseIsRemovedOnlyWithMoveTo() throws Exception { + WeblogCategory used = TestUtils.setupWeblogCategory(managedWeblog(), "Used"); + WeblogCategory target = TestUtils.setupWeblogCategory(managedWeblog(), "Target"); + WeblogEntry entry = TestUtils.setupWeblogEntry("inuse", used, managedWeblog(), + TestUtils.getManagedUser(owner)); + TestUtils.endSession(true); + + AtomException refused = assertThrows(AtomException.class, () -> categories(author) + .deleteEntry(request("/" + HANDLE + "/category/" + used.getId()))); + assertEquals(HttpServletResponse.SC_CONFLICT, refused.getStatus()); + TestUtils.endSession(false); + + categories(author).deleteEntry(request("/" + HANDLE + "/category/" + used.getId(), + "moveTo", target.getId())); + TestUtils.endSession(true); + + assertNull(entryManager().getWeblogCategory(managedWeblog(), used.getId())); + assertEquals(target.getId(), + entryManager().getWeblogEntry(entry.getId()).getCategory().getId()); + } + + @Test + public void defaultCategoryRemovedWithMoveToHandsOverTheDefault() throws Exception { + WeblogCategory target = TestUtils.setupWeblogCategory(managedWeblog(), "Target"); + TestUtils.endSession(true); + String defaultId = managedWeblog().getBloggerCategory().getId(); + + // the default category counts as in use + AtomException refused = assertThrows(AtomException.class, () -> categories(author) + .deleteEntry(request("/" + HANDLE + "/category/" + defaultId))); + assertEquals(HttpServletResponse.SC_CONFLICT, refused.getStatus()); + TestUtils.endSession(false); + + categories(author).deleteEntry(request("/" + HANDLE + "/category/" + defaultId, + "moveTo", target.getId())); + TestUtils.endSession(true); + + assertEquals(target.getId(), managedWeblog().getBloggerCategory().getId()); + // the collection still lists cleanly + AtomFeed feed = categories(author).getCollection(request("/" + HANDLE + "/categories")); + assertTrue(feed.getEntries().stream().anyMatch(e -> "Target".equals(e.getTitle()))); + } + + @Test + public void lastCategoryCannotBeRemoved() throws Exception { + WeblogEntryManager wem = entryManager(); + List cats = wem.getWeblogCategories(managedWeblog()); + WeblogCategory keep = cats.get(0); + for (WeblogCategory cat : cats.subList(1, cats.size())) { + categories(author).deleteEntry(request("/" + HANDLE + "/category/" + cat.getId(), + "moveTo", keep.getId())); + TestUtils.endSession(true); + } + assertEquals(1, entryManager().getWeblogCategories(managedWeblog()).size()); + + AtomException refused = assertThrows(AtomException.class, () -> categories(author) + .deleteEntry(request("/" + HANDLE + "/category/" + keep.getId()))); + assertEquals(HttpServletResponse.SC_CONFLICT, refused.getStatus()); + } + + @Test + public void categoryImageMustBeAnHttpUrl() throws Exception { + for (String image : new String[] {"javascript:alert(1)", "https://example.com/a\" b", + "ftp://example.com/a.png", "/images/a.png"}) { + AtomEntry in = new AtomEntry(); + in.setTitle("Pictures"); + in.setExtension("image", image); + AtomException refused = assertThrows(AtomException.class, () -> categories(author) + .postEntry(request("/" + HANDLE + "/categories"), in), image); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, refused.getStatus(), image); + TestUtils.endSession(false); + } + + AtomEntry ok = new AtomEntry(); + ok.setTitle("Pictures"); + ok.setExtension("image", "https://example.com/images/a.png"); + AtomEntry created = categories(author).postEntry(request("/" + HANDLE + "/categories"), ok); + assertEquals("https://example.com/images/a.png", created.getExtension("image")); + } + + @Test + public void overlongCategoryAndTemplateValuesAreRefused() throws Exception { + String tooLong = "x".repeat(256); + + AtomEntry category = new AtomEntry(); + category.setTitle("Long"); + category.setSummary(CollectionSupport.text("text", tooLong)); + AtomException refused = assertThrows(AtomException.class, () -> categories(author) + .postEntry(request("/" + HANDLE + "/categories"), category)); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, refused.getStatus()); + TestUtils.endSession(false); + + useCustomTheme(); + AtomEntry template = new AtomEntry(); + template.setTitle("sidebar"); + template.setExtension("link", tooLong); + AtomException refusedTemplate = assertThrows(AtomException.class, () -> templates(owner) + .postEntry(request("/" + HANDLE + "/templates"), template)); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, refusedTemplate.getStatus()); + } + + @Test + public void categoriesNeedPermissionOnTheWeblog() throws Exception { + assertThrows(AtomNotAuthorizedException.class, () -> categories(author) + .getCollection(request("/" + OTHER_HANDLE + "/categories"))); + } + + // ------------------------------------------------------------- comments + + @Test + public void commentModeration() throws Exception { + WeblogEntry entry = TestUtils.setupWeblogEntry("commented", managedWeblog(), + TestUtils.getManagedUser(owner)); + WeblogEntryComment pending = TestUtils.setupComment("pending one", entry); + pending.setStatus(ApprovalStatus.PENDING); + entryManager().saveComment(pending); + WeblogEntryComment approved = TestUtils.setupComment("approved one", entry); + TestUtils.endSession(true); + + // list only pending comments + AtomFeed feed = comments(author).getCollection( + request("/" + HANDLE + "/comments", "status", "pending")); + List statuses = feed.getEntries().stream() + .map(e -> e.getExtension("status")).collect(Collectors.toList()); + assertEquals(List.of("pending"), statuses); + AtomEntry listed = feed.getEntries().get(0); + assertEquals(entry.getPermalink(), listed.getInReplyToRef()); + assertEquals("test", listed.getAuthors().get(0).getName()); + + // approve it + AtomEntry change = new AtomEntry(); + change.setExtension("status", "approved"); + comments(author).putEntry(request("/" + HANDLE + "/comment/" + pending.getId()), change); + TestUtils.endSession(true); + assertEquals(ApprovalStatus.APPROVED, entryManager().getComment(pending.getId()).getStatus()); + + // an unknown status is refused + AtomEntry bad = new AtomEntry(); + bad.setExtension("status", "maybe"); + AtomException invalid = assertThrows(AtomException.class, () -> comments(author) + .putEntry(request("/" + HANDLE + "/comment/" + pending.getId()), bad)); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, invalid.getStatus()); + + // delete the other one + comments(author).deleteEntry(request("/" + HANDLE + "/comment/" + approved.getId())); + TestUtils.endSession(true); + assertNull(entryManager().getComment(approved.getId())); + } + + @Test + public void commentsOfAnotherWeblogAreNotFound() throws Exception { + WeblogEntry entry = TestUtils.setupWeblogEntry("elsewhere", managedOther(), + TestUtils.getManagedUser(owner)); + WeblogEntryComment comment = TestUtils.setupComment("other", entry); + TestUtils.endSession(true); + + assertThrows(AtomNotFoundException.class, () -> comments(author) + .getEntry(request("/" + HANDLE + "/comment/" + comment.getId()))); + } + + // ------------------------------------------------------ service document + + @Test + public void serviceDocumentListsTheCollectionsTheUserMayUse() throws Exception { + List authorHrefs = hrefs(author); + assertTrue(authorHrefs.contains(ATOM_URL + "/" + HANDLE + "/categories")); + assertTrue(authorHrefs.contains(ATOM_URL + "/" + HANDLE + "/comments")); + assertFalse(authorHrefs.contains(ATOM_URL + "/" + HANDLE + "/templates")); + + // the owner sees templates only once the weblog uses a custom theme + assertFalse(hrefs(owner).contains(ATOM_URL + "/" + HANDLE + "/templates")); + useCustomTheme(); + assertTrue(hrefs(owner).contains(ATOM_URL + "/" + HANDLE + "/templates")); + assertFalse(hrefs(author).contains(ATOM_URL + "/" + HANDLE + "/templates")); + } + + // -------------------------------------------------------------- helpers + + private List hrefs(User user) throws Exception { + AtomServiceDoc doc = new RollerAtomService(TestUtils.getManagedUser(user), ATOM_URL) + .getServiceDoc(); + return doc.getWorkspaces().stream() + .flatMap(w -> w.getCollections().stream()) + .map(AtomCollection::getHref) + .collect(Collectors.toList()); + } + + private void useCustomTheme() throws Exception { + Weblog managed = managedWeblog(); + managed.setEditorTheme(WeblogTheme.CUSTOM); + weblogManager().saveWeblog(managed); + TestUtils.endSession(true); + } + + private static void setProperty(String name, String value) throws Exception { + PropertiesManager pmgr = WebloggerFactory.getWeblogger().getPropertiesManager(); + RuntimeConfigProperty property = pmgr.getProperty(name); + property.setValue(value); + pmgr.saveProperty(property); + } + + private TemplateCollection templates(User user) throws Exception { + return new TemplateCollection(TestUtils.getManagedUser(user), ATOM_URL); + } + + private CategoryCollection categories(User user) throws Exception { + return new CategoryCollection(TestUtils.getManagedUser(user), ATOM_URL); + } + + private CommentCollection comments(User user) throws Exception { + return new CommentCollection(TestUtils.getManagedUser(user), ATOM_URL); + } + + private Weblog managedWeblog() throws Exception { + return weblogManager().getWeblogByHandle(HANDLE); + } + + private Weblog managedOther() throws Exception { + return weblogManager().getWeblogByHandle(OTHER_HANDLE); + } + + private static WeblogManager weblogManager() { + return WebloggerFactory.getWeblogger().getWeblogManager(); + } + + private static WeblogEntryManager entryManager() { + return WebloggerFactory.getWeblogger().getWeblogEntryManager(); + } + + private static String idFrom(AtomEntry entry, String marker) { + String edit = entry.getLinkHref("edit"); + assertNotNull(edit, "entry must have an edit link"); + return edit.substring(edit.lastIndexOf(marker) + marker.length()); + } + + private static AtomRequest request(String pathInfo) { + return request(pathInfo, null, null); + } + + private static AtomRequest request(String pathInfo, String param, String value) { + HttpServletRequest req = mock(HttpServletRequest.class); + lenient().when(req.getPathInfo()).thenReturn(pathInfo); + if (param != null) { + lenient().when(req.getParameter(param)).thenReturn(value); + } + return new AtomRequest(req, null); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java index 0e791e43b9..da0a7f8a34 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java @@ -186,6 +186,24 @@ public void testEntryLifecycle() throws Exception { assertNull(wem.getWeblogEntry(entryId)); } + @Test + public void testTagsUseTheSharedTagCharacterRules() throws Exception { + AtomEntry in = sampleEntry("Tagged", "

x

", false); + AtomCategory tag = new AtomCategory(); + tag.setTerm("rock&roll\"live\""); + in.getCategories().add(tag); + EntryCollection ecol = new EntryCollection(managedUser(), ATOM_URL); + AtomEntry created = ecol.postEntry( + request("/" + HANDLE + "/entries", "application/atom+xml", null, null), in); + String entryId = entryIdFromEditLink(created); + TestUtils.endSession(true); + + WeblogEntry persisted = WebloggerFactory.getWeblogger().getWeblogEntryManager() + .getWeblogEntry(entryId); + assertTrue(persisted.getTags().stream().anyMatch(t -> "rockrolllive".equals(t.getName())), + () -> persisted.getTags().toString()); + } + @Test public void testServiceDocument() throws Exception { AtomServiceDoc service = new RollerAtomService(managedUser(), ATOM_URL).getServiceDoc(); @@ -199,9 +217,11 @@ public void testServiceDocument() throws Exception { .findFirst().orElse(null); assertNotNull(entries, "service doc should expose an entries collection"); assertTrue(entries.getAccepts().contains("application/atom+xml;type=entry")); - // a fixed categories block (weblog categories) plus a free-form one - assertEquals(2, entries.getCategories().size()); + // the weblog's categories inline and by reference, plus a free-form block + assertEquals(3, entries.getCategories().size()); assertTrue(entries.getCategories().stream().anyMatch(AtomCategories::isFixed)); + assertTrue(entries.getCategories().stream().anyMatch(c -> + (ATOM_URL + "/" + HANDLE + "/categories.atomcat").equals(c.getHref()))); // the document serializes to well-formed XML java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java index c603b60414..1e62f1b71c 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java @@ -41,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; @@ -334,6 +335,36 @@ void createdEntryLocationIsSetOnlyWhenSafe() throws Exception { verify(unsafe, never()).setHeader(eq("Location"), anyString()); } + @Test + void categoryDocumentIsServedAsAtomcat() throws Exception { + HttpServletResponse catsResponse = mock(HttpServletResponse.class); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(catsResponse.getOutputStream()).thenReturn(new ServletOutputStream() { + @Override + public void write(int b) { + body.write(b); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener listener) { + throw new UnsupportedOperationException(); + } + }); + + servlet.service(request("GET", "/blog/categories.atomcat", null, ""), catsResponse); + + verify(catsResponse).setContentType(AtomConstants.CATEGORIES_MEDIA_TYPE); + verify(servlet.handler, never()).getCollection(any()); + String xml = body.toString(StandardCharsets.UTF_8); + assertTrue(xml.contains(" + call.getArgument(0).getPathInfo().endsWith("/categories.atomcat")); + AtomCategories cats = new AtomCategories(); + cats.setFixed(true); + AtomCategory travel = new AtomCategory(); + travel.setTerm("Travel"); + cats.getCategories().add(travel); + when(handler.getCategoriesDocument(any())).thenReturn(cats); when(handler.isCollectionURI(any())).thenAnswer(call -> call.getArgument(0).getPathInfo().matches("/[^/]+/(entries|resources).*")); when(handler.isEntryURI(any())).thenAnswer(call -> diff --git a/assembly-release/src/main/assembly/binary.xml b/assembly-release/src/main/assembly/binary.xml index ae75c95c5f..85ea353fb1 100644 --- a/assembly-release/src/main/assembly/binary.xml +++ b/assembly-release/src/main/assembly/binary.xml @@ -51,6 +51,10 @@ target/generated-docs/roller-template-guide.pdf ${artifact.artifactId}-${artifact.version}/docs + + target/generated-docs/roller-atompub-guide.pdf + ${artifact.artifactId}-${artifact.version}/docs + target/generated-docs/roller-user-guide.pdf ${artifact.artifactId}-${artifact.version}/docs diff --git a/docs/README.md b/docs/README.md index 6991392fa9..6fb2052a2b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ In this directory you'll find Roller docs in AsciiDoc format, various examples a * `roller-install-guide.adoc` - How to install Roller * `roller-user-guide.adoc` - How to use Roller * `roller-template-guide.adoc` - How to create Roller templates and themes +* `roller-atompub-guide.adoc` - Reference for Roller's AtomPub interface; its examples and extension schema are in `atompub/` * `security-model.md` - Who Roller trusts, what each user may do, and what counts as a vulnerability * `examples/` - example configuration and script files * `testing/` - Various test scripts diff --git a/docs/atompub/examples/README.txt b/docs/atompub/examples/README.txt new file mode 100644 index 0000000000..bbca405017 --- /dev/null +++ b/docs/atompub/examples/README.txt @@ -0,0 +1,4 @@ +Examples included by ../../roller-atompub-guide.adoc. Files ending in +-request.xml are written by hand; the others are generated by +AtomPubDocExamplesTest (mvn -pl app test -Dtest=AtomPubDocExamplesTest +-Datompub.docs.update=true). The test fails when they no longer match. diff --git a/docs/atompub/examples/categories.xml b/docs/atompub/examples/categories.xml new file mode 100644 index 0000000000..67ef325423 --- /dev/null +++ b/docs/atompub/examples/categories.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/atompub/examples/category-entry.xml b/docs/atompub/examples/category-entry.xml new file mode 100644 index 0000000000..f3cfac6c0d --- /dev/null +++ b/docs/atompub/examples/category-entry.xml @@ -0,0 +1,14 @@ + + + https://example.com/roller/roller-services/app/myblog/category/7c3e9a10 + Travel + 2026-10-10T12:30:00Z + Trips and places + + https://example.com/images/travel.png + 2 + true + + no + + diff --git a/docs/atompub/examples/category-request.xml b/docs/atompub/examples/category-request.xml new file mode 100644 index 0000000000..3f1b86ccb7 --- /dev/null +++ b/docs/atompub/examples/category-request.xml @@ -0,0 +1,11 @@ + + + urn:uuid:5b1f7a1e-0b6a-4c0e-9a37-3f1d2c4e8b11 + Travel + 2026-10-10T12:00:00Z + + dave + + Trips and places + https://example.com/images/travel.png + diff --git a/docs/atompub/examples/comment-entry.xml b/docs/atompub/examples/comment-entry.xml new file mode 100644 index 0000000000..e169b70948 --- /dev/null +++ b/docs/atompub/examples/comment-entry.xml @@ -0,0 +1,21 @@ + + + https://example.com/roller/roller-services/app/myblog/comment/8f0e2a6c + Comment on Hello AtomPub + 2026-10-10T12:00:00Z + 2026-10-10T12:00:00Z + + Pat Reader + https://pat.example.org/ + pat@example.org + + Nice post! + + + + pending + 192.0.2.10 + + no + + diff --git a/docs/atompub/examples/comment-status-request.xml b/docs/atompub/examples/comment-status-request.xml new file mode 100644 index 0000000000..d74140ef2d --- /dev/null +++ b/docs/atompub/examples/comment-status-request.xml @@ -0,0 +1,10 @@ + + + https://example.com/roller/roller-services/app/myblog/comment/8f0e2a6c + Comment on Hello AtomPub + 2026-10-10T12:00:00Z + + dave + + approved + diff --git a/docs/atompub/examples/comments-feed.xml b/docs/atompub/examples/comments-feed.xml new file mode 100644 index 0000000000..4b4e5ba4eb --- /dev/null +++ b/docs/atompub/examples/comments-feed.xml @@ -0,0 +1,27 @@ + + + https://example.com/roller/roller-services/app/myblog/comments/0 + My Weblog comments + 2026-10-10T12:30:00Z + + + https://example.com/roller/roller-services/app/myblog/comment/8f0e2a6c + Comment on Hello AtomPub + 2026-10-10T12:00:00Z + 2026-10-10T12:00:00Z + + Pat Reader + https://pat.example.org/ + pat@example.org + + Nice post! + + + + pending + 192.0.2.10 + + no + + + diff --git a/docs/atompub/examples/entries-feed.xml b/docs/atompub/examples/entries-feed.xml new file mode 100644 index 0000000000..a9211a9aae --- /dev/null +++ b/docs/atompub/examples/entries-feed.xml @@ -0,0 +1,27 @@ + + + https://example.com/roller/roller-services/app/myblog/entries/0 + My Weblog + 2026-10-10T12:30:00Z + + + + https://example.com/roller/myblog/entry/hello_atompub + Hello AtomPub + 2026-10-10T12:00:00Z + 2026-10-10T12:30:00Z + + dave + dave@example.com + + + + <p>Posted with <b>AtomPub</b>.</p> + + + 2026-10-10T12:30:00Z + + no + + + diff --git a/docs/atompub/examples/entry-request.xml b/docs/atompub/examples/entry-request.xml new file mode 100644 index 0000000000..4fb2779ac3 --- /dev/null +++ b/docs/atompub/examples/entry-request.xml @@ -0,0 +1,15 @@ + + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + Hello AtomPub + 2026-10-10T12:00:00Z + + dave + + + + <p>Posted with <b>AtomPub</b>.</p> + + no + + diff --git a/docs/atompub/examples/entry-response.xml b/docs/atompub/examples/entry-response.xml new file mode 100644 index 0000000000..b513e743c7 --- /dev/null +++ b/docs/atompub/examples/entry-response.xml @@ -0,0 +1,20 @@ + + + https://example.com/roller/myblog/entry/hello_atompub + Hello AtomPub + 2026-10-10T12:00:00Z + 2026-10-10T12:30:00Z + + dave + dave@example.com + + + + <p>Posted with <b>AtomPub</b>.</p> + + + 2026-10-10T12:30:00Z + + no + + diff --git a/docs/atompub/examples/media-link-entry.xml b/docs/atompub/examples/media-link-entry.xml new file mode 100644 index 0000000000..5427acd06d --- /dev/null +++ b/docs/atompub/examples/media-link-entry.xml @@ -0,0 +1,14 @@ + + + https://example.com/roller/roller-services/app/myblog/resource/default/sunset.jpg + sunset.jpg + 2026-10-10T12:30:00Z + + + + + 2026-10-10T12:30:00Z + + no + + diff --git a/docs/atompub/examples/service.xml b/docs/atompub/examples/service.xml new file mode 100644 index 0000000000..f14322575b --- /dev/null +++ b/docs/atompub/examples/service.xml @@ -0,0 +1,34 @@ + + + + My Weblog + + Weblog Entries + application/atom+xml;type=entry + + + + + + + + + + Media Files: default + image/* + application/pdf + + + Templates + application/atom+xml;type=entry + + + Categories + application/atom+xml;type=entry + + + Comments + + + + diff --git a/docs/atompub/examples/template-entry.xml b/docs/atompub/examples/template-entry.xml new file mode 100644 index 0000000000..b340626fec --- /dev/null +++ b/docs/atompub/examples/template-entry.xml @@ -0,0 +1,19 @@ + + + https://example.com/roller/roller-services/app/myblog/template/0e5d8b62 + sidebar + 2026-10-10T12:30:00Z + The sidebar + <div class="sidebar">$model.weblog.name</div> + + <div class="sidebar-mobile">$model.weblog.name</div> + custom + sidebar + true + false + false + 2026-10-10T12:30:00Z + + no + + diff --git a/docs/atompub/examples/template-request.xml b/docs/atompub/examples/template-request.xml new file mode 100644 index 0000000000..5ba17d71eb --- /dev/null +++ b/docs/atompub/examples/template-request.xml @@ -0,0 +1,14 @@ + + + urn:uuid:9e0c4f8a-6d3b-4a51-b2f7-0c8e1d5a7f20 + sidebar + 2026-10-10T12:00:00Z + + dave + + The sidebar + <div class="sidebar">$model.weblog.name</div> + <div class="sidebar-mobile">$model.weblog.name</div> + custom + true + diff --git a/docs/atompub/roller-atompub.rnc b/docs/atompub/roller-atompub.rnc new file mode 100644 index 0000000000..2c7bb6f5e6 --- /dev/null +++ b/docs/atompub/roller-atompub.rnc @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. 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. For additional information regarding +# copyright in this work, please see the NOTICE file in the top level +# directory of this distribution. + +# RELAX NG Compact grammar for the extension elements that Roller's AtomPub +# service adds to atom:entry: Roller's own elements and the thr:in-reply-to +# element of the Atom Threading Extensions (RFC 4685). Atom allows these as +# foreign elements, so an entry that uses them is still valid against the +# RFC 4287 grammar. This grammar checks the elements themselves; its start +# pattern matches one extension element. + +namespace roller = "http://roller.apache.org/ns/atompub/1.0" +namespace thr = "http://purl.org/syndication/thread/1.0" + +start = rollerElement | thrInReplyTo + +boolean = "true" | "false" + +rollerElement = + # Templates + element roller:rendition { attribute type { "mobile" }, text } + | element roller:action { + "weblog" | "permalink" | "search" | "tagsindex" | "stylesheet" | "custom" + } + | element roller:link { text } + | element roller:navbar { boolean } + | element roller:hidden { boolean } + | element roller:required { boolean } + | element roller:contentType { text } + # Categories + | element roller:image { text } + | element roller:position { xsd:nonNegativeInteger } + | element roller:inUse { boolean } + # Comments + | element roller:status { "approved" | "disapproved" | "spam" | "pending" } + | element roller:remoteHost { text } + | element roller:referrer { text } + +thrInReplyTo = + element thr:in-reply-to { + attribute ref { text }, + attribute href { text }?, + attribute type { text }?, + attribute source { text }?, + empty + } diff --git a/docs/roller-atompub-guide.adoc b/docs/roller-atompub-guide.adoc new file mode 100644 index 0000000000..bc7a042846 --- /dev/null +++ b/docs/roller-atompub-guide.adoc @@ -0,0 +1,508 @@ += AtomPub Guide +Apache Roller Weblogger +:toc: +:sectnums: +:imagesdir: ./images + +== Overview + +This is the reference for the Atom Publishing Protocol (AtomPub) interface of +the Apache Roller Weblogger. With it, a program can create and edit weblog +entries, upload media files, and manage a weblog's categories, templates and +comments over HTTP. + +Roller implements AtomPub as defined in +https://www.rfc-editor.org/rfc/rfc5023[RFC 5023], using the Atom format of +https://www.rfc-editor.org/rfc/rfc4287[RFC 4287]. Categories, templates and +comments are Roller extensions; they use ordinary Atom entries plus a few +extension elements, so any AtomPub client library can read and write them. + +=== Copyright and trademark information + +The contents of this document are subject to the terms of the Apache +Software License. + +All trademarks within this document belong to legitimate owners. + +=== Conventions + +* All URIs in this guide are relative to the AtomPub base URL, +`https:////roller-services/app`. In the examples the base URL is +`https://example.com/roller/roller-services/app`. +* `{h}` stands for a weblog handle, for example `myblog`. +* Namespaces and the prefixes used in this guide: ++ +[cols="1,3",options="header"] +|=== +|Prefix |Namespace +|(default) |`http://www.w3.org/2005/Atom` (Atom, RFC 4287) +|`app` |`http://www.w3.org/2007/app` (AtomPub, RFC 5023) +|`roller` |`http://roller.apache.org/ns/atompub/1.0` (Roller extensions) +|`thr` |`http://purl.org/syndication/thread/1.0` (Atom Threading, RFC 4685) +|=== +* Media types: ++ +[cols="2,3",options="header"] +|=== +|Media type |Used for +|`application/atomsvc+xml` |The service document +|`application/atom+xml;type=feed` |Collections (feeds) +|`application/atom+xml;type=entry` |Entries, in requests and responses +|`application/atomcat+xml` |Category documents +|=== + +The XML examples in this guide are generated from Roller's own code by its +test suite, so they show exactly what the server writes. + +== Enabling AtomPub + +A Roller administrator controls AtomPub in *Server Administration > Configuration +> Web Services Settings*: + +[cols="2,1,4",options="header"] +|=== +|Setting |Default |Meaning +|`webservices.enableAtomPub` |`false` +|Turns AtomPub on. While it is off, every AtomPub URL answers `404 Not Found`. +|`webservices.atomPubAuth` |`basic` +|Authentication method: `basic` or `oauth`. Any other value refuses all +requests (see <>). +|`webservices.atomPubMaxEntrySize` |`1048576` +|Largest Atom entry body accepted, in bytes. A larger entry gets +`413 Request Entity Too Large`. Media uploads are not affected; they use the +file upload limits. +|`themes.customtheme.allowed` |`false` +|Must be on for the templates collection to exist. +|=== + +Media uploads also need *File Upload Settings*: uploads enabled, the allowed +file types, and the size and quota limits. + +== Authentication + +Every AtomPub request must be authenticated. Roller checks the credentials +before it reads the request body. A request that fails authentication gets +`401 Unauthorized`. + +Always use HTTPS: BASIC authentication sends the password with every request. + +=== BASIC + +With `webservices.atomPubAuth` set to `basic`, send the Roller user name and +password in an HTTP `Authorization` header: + +---- +Authorization: Basic ZGF2ZTpzZWNyZXQ= +---- + +The user must be enabled. Without valid credentials Roller answers `401` with +`WWW-Authenticate: Basic realm="Roller"`. + +=== OAuth + +With `webservices.atomPubAuth` set to `oauth`, clients use OAuth 1.0a. Each +user finds their consumer key and secret, and the OAuth URLs, through the +*OAuth Credentials* link on the *Your Weblogs* page (shown while OAuth is on). +Administrators also see site-wide keys. + +[cols="2,3",options="header"] +|=== +|Step |URL +|Get a request token |`//roller-services/oauth/requestToken` +|User authorizes the token |`//roller-services/oauth/authorize` +|Exchange for an access token |`//roller-services/oauth/accessToken` +|=== + +Sign each AtomPub request with the access token, as OAuth 1.0a describes. + +== Discovery: the service document + +`GET /` returns the service document. It has one workspace for each weblog the +user is a member of, and lists only the collections the user may use: + +[cols="2,3",options="header"] +|=== +|Collection |Listed when +|Weblog Entries, Media Files (one per media directory) +|The user is a member of the weblog +|Categories, Comments +|The user may post to the weblog (permission `POST` or higher) +|Templates +|The user is an administrator of the weblog (`ADMIN`), the weblog uses a custom +theme, and the site allows custom themes +|=== + +The entries collection describes the weblog's categories twice: inline, for +older clients, and as a link to the weblog's category document. A third, +empty `app:categories` element says that any other term is accepted as a tag. +The comments collection has an empty `app:accept`: it does not accept `POST`. + +.Service document +[source,xml] +---- +include::atompub/examples/service.xml[] +---- + +== Requests and responses + +* Send entries with `Content-Type: application/atom+xml;type=entry`. +* Roller refuses an entry that is not well-formed XML or that has a `DOCTYPE` +(`400 Bad Request`). +* A successful `POST` answers `201 Created` with the new member as the body and +its edit URI in the `Location` and `Content-Location` headers. +* A successful `PUT` or `DELETE` answers `200 OK` without a body. +* The entries, media and comments collections return at most 20 members. When +there are more, the feed has `next` and `previous` links; follow them to page +through. The categories and templates collections return all members. + +== Weblog entries + +[cols="2,1,4",options="header"] +|=== +|URI |Method |Action +|`/{h}/entries`, `/{h}/entries/{offset}` |GET |List entries, newest first +|`/{h}/entries` |POST |Create an entry +|`/{h}/entry/{id}` |GET |Read an entry +|`/{h}/entry/{id}` |PUT |Replace an entry +|`/{h}/entry/{id}` |DELETE |Delete an entry +|=== + +*Permission:* to list or create entries, `POST` on the weblog. To read, change +or delete an entry, the user must be allowed to edit that entry. + +[cols="2,1,4",options="header"] +|=== +|Element |Access |Meaning +|`atom:title` |read/write |Entry title +|`atom:content` |read/write |Entry text, as HTML (`type="html"`) +|`atom:summary` |read/write |Entry summary, as HTML +|`atom:published` |read/write |Publication time; now when missing +|`atom:updated` |read/write |Update time; now when missing +|`atom:category` with the weblog's scheme |read/write +|The weblog category. The scheme is the weblog's URL. Roller keeps the first +one that names an existing category; otherwise it uses the weblog's default +category. +|`atom:category` without a scheme |read/write |A tag +|`app:control/app:draft` |read/write |`yes` saves a draft; `no` publishes +|`atom:author` |read |The user who created the entry; ignored on input +|`atom:link rel="alternate"` |read |The entry's permalink +|`app:edited` |read |Last change +|=== + +.Creating an entry (request) +[source,xml] +---- +include::atompub/examples/entry-request.xml[] +---- + +.Created entry (response) +[source,xml] +---- +include::atompub/examples/entry-response.xml[] +---- + +.Entries collection +[source,xml] +---- +include::atompub/examples/entries-feed.xml[] +---- + +== Media files + +[cols="2,1,4",options="header"] +|=== +|URI |Method |Action +|`/{h}/resources/{dir}`, `/{h}/resources/{dir}/{offset}` |GET +|List the files in a media directory +|`/{h}/resources`, `/{h}/resources/{dir}` |POST +|Upload a file to the default or the named directory +|`/{h}/resource/{dir}/{name}.media-link` |GET |Read the media link entry +|`/{h}/resource/{dir}/{name}` |GET |Download the file +|`/{h}/resource/{dir}/{name}` |PUT |Replace the file's contents +|`/{h}/resource/{dir}/{name}` or `....media-link` |DELETE |Delete the file +|=== + +*Permission:* `POST` on the weblog. + +To upload, `POST` the file's bytes with its media type in `Content-Type` and a +file name in the `Slug` header: + +---- +POST /myblog/resources/default HTTP/1.1 +Content-Type: image/jpeg +Slug: sunset +---- + +* Without `Slug`, Roller names the file from the weblog handle and the date. +The file name extension comes from the media type. +* The media type must be one the site allows; the service document lists them +in each media collection's `app:accept`. Roller stores the type it derives +from the file name and contents, not only the one the client sends. +* A `POST` without `Content-Type` gets `415 Unsupported Media Type`. +* A file that breaks the upload rules (type, size, quota) or that already +exists is refused. + +Downloads of types that a browser could run, such as HTML, are served as +attachments with `X-Content-Type-Options: nosniff`. + +.Media link entry +[source,xml] +---- +include::atompub/examples/media-link-entry.xml[] +---- + +== Categories + +=== Category document + +`GET /{h}/categories.atomcat` returns the weblog's categories as an RFC 5023 +category document. Clients use it to offer category choices for new entries. + +*Permission:* `POST` on the weblog. + +[source,xml] +---- +include::atompub/examples/categories.xml[] +---- + +=== Categories collection + +[cols="2,1,4",options="header"] +|=== +|URI |Method |Action +|`/{h}/categories` |GET |List the weblog's categories +|`/{h}/categories` |POST |Create a category +|`/{h}/category/{id}` |GET |Read a category +|`/{h}/category/{id}` |PUT |Change a category +|`/{h}/category/{id}`, `/{h}/category/{id}?moveTo={otherId}` |DELETE +|Delete a category +|=== + +*Permission:* `POST` on the weblog. + +[cols="2,1,4",options="header"] +|=== +|Element |Access |Meaning +|`atom:title` |read/write +|Name; required on `POST`; at most 255 characters. It must be unique in the +weblog and may not contain `<`, `>`, `&` or `"`. +|`atom:summary` |read/write |Description, at most 255 characters +|`roller:image` |read/write |Image URL (`http` or `https`); empty removes it +|`roller:position` |read |Order in the weblog's category list +|`roller:inUse` |read |`true` when entries use the category, or it is the +weblog's default category +|=== + +A duplicate name gets `409 Conflict`. Deleting a category is refused with +`409 Conflict` when: + +* entries use it, or it is the weblog's default category, and the request has no +`moveTo`. With `moveTo={otherId}`, Roller first moves the entries to that +category, which also becomes the default if needed; +* it is the weblog's only category. + +.Creating a category (request) +[source,xml] +---- +include::atompub/examples/category-request.xml[] +---- + +.Category entry +[source,xml] +---- +include::atompub/examples/category-entry.xml[] +---- + +== Templates + +The templates collection lets a client edit the templates of a weblog that uses +a custom theme. Weblogs that use a shared theme have no templates collection: +its URIs answer `404 Not Found`, even for administrators. + +[cols="2,1,4",options="header"] +|=== +|URI |Method |Action +|`/{h}/templates` |GET |List the weblog's templates +|`/{h}/templates` |POST |Create a template +|`/{h}/template/{id}` |GET |Read a template +|`/{h}/template/{id}` |PUT |Change a template +|`/{h}/template/{id}` |DELETE |Delete a template +|=== + +*Permission:* `ADMIN` on the weblog. The weblog must use a custom theme and +`themes.customtheme.allowed` must be on. + +[cols="2,1,4",options="header"] +|=== +|Element |Access |Meaning +|`atom:title` |read/write |Name; required on `POST`; unique in the weblog; at +most 255 characters +|`atom:summary` |read/write |Description, at most 255 characters +|`atom:content type="text"` |read/write |The standard template (Velocity) +|`roller:rendition type="mobile"` |read/write +|The mobile template. Missing on `PUT`: unchanged. Empty: removed, so mobile +visitors see the standard template. +|`roller:action` |read/write +|`weblog`, `permalink`, `search`, `tagsindex`, `stylesheet` or `custom`. +Set on `POST` only; `custom` when missing. +|`roller:link` |read/write |The template's URL path; unique in the weblog; at +most 255 characters +|`roller:navbar` |read/write |`true` to list the template in the navigation bar +|`roller:hidden` |read/write |`true` to hide the template from visitors +|`roller:contentType` |read/write |Output media type; empty for automatic +|`roller:required` |read |`true` for templates the theme needs +|=== + +Rules, the same as on the *Templates* pages: + +* Each action other than `custom` may have one template. Adding a second one, or +a `stylesheet` template, gets `409 Conflict`. +* A `weblog` template is always named `Weblog`. +* The name, description, link, navigation bar and hidden settings change only +for custom templates. For the others Roller changes only the template text and +the content type. +* Required templates (`Weblog` and `_day`) cannot be deleted (`409 Conflict`). + +Template text is Velocity markup. Send it as escaped text (`<div>`), not +as XML elements, or use a CDATA section. + +.Creating a template (request) +[source,xml] +---- +include::atompub/examples/template-request.xml[] +---- + +.Template entry +[source,xml] +---- +include::atompub/examples/template-entry.xml[] +---- + +== Comments + +The comments collection is for comment moderation. It does not accept new +comments. + +[cols="2,1,4",options="header"] +|=== +|URI |Method |Action +|`/{h}/comments`, `/{h}/comments/{offset}` |GET +|List comments, newest first. Filter with `?status=` (`approved`, +`disapproved`, `spam`, `pending`) and `?entry={entryId}`. +|`/{h}/comments` |POST |Not allowed (`405 Method Not Allowed`) +|`/{h}/comment/{id}` |GET |Read a comment +|`/{h}/comment/{id}` |PUT |Change the comment's status +|`/{h}/comment/{id}` |DELETE |Delete a comment +|=== + +*Permission:* `POST` on the weblog. + +[cols="2,1,4",options="header"] +|=== +|Element |Access |Meaning +|`roller:status` |read/write +|`approved`, `disapproved`, `spam` or `pending`. The only element a `PUT` uses. +|`atom:author` |read |The commenter's name, email address and web site +|`atom:content` |read |The comment text +|`atom:published` |read |When the comment was posted +|`thr:in-reply-to` |read |The weblog entry: its id (`ref`) and edit URI +(`href`) +|`roller:remoteHost`, `roller:referrer` |read |Where the comment came from +|=== + +When a `pending` comment is approved, Roller sends the same notification email +as the *Comments* page, if mail is configured. + +.Changing a comment's status (request) +[source,xml] +---- +include::atompub/examples/comment-status-request.xml[] +---- + +.Comments collection +[source,xml] +---- +include::atompub/examples/comments-feed.xml[] +---- + +== Errors + +Errors carry a short message. Server errors (`5xx`) carry no detail; the cause +is in Roller's log. + +[cols="1,4",options="header"] +|=== +|Status |Meaning +|`400 Bad Request` |The entry is not well-formed, has a `DOCTYPE`, or has an +invalid value (unknown action or status, invalid category name or image URL, +a value longer than 255 characters, bad `moveTo`) +|`401 Unauthorized` |Authentication failed, or the user lacks the permission the +resource needs +|`404 Not Found` |AtomPub is off, the resource does not exist or belongs to +another weblog, or the collection is not available (for example templates of a +shared theme) +|`405 Method Not Allowed` |The method is not supported, for example `POST` to +the comments collection +|`409 Conflict` |The change conflicts with the weblog: duplicate name or link, +a template action that is taken, a required template, a category in use or the +last category +|`413 Request Entity Too Large` |The entry is larger than +`webservices.atomPubMaxEntrySize` +|`415 Unsupported Media Type` |A `POST` without `Content-Type`, or media sent to +a collection that accepts only entries +|`500 Internal Server Error` |An unexpected error +|=== + +== Example session + +These `curl` commands use BASIC authentication. Replace the host, user and IDs +with your own. + +[source,bash] +---- +APP=https://example.com/roller/roller-services/app +AUTH="dave:secret" + +# 1. Discover the weblogs and collections +curl -u "$AUTH" "$APP/" + +# 2. Create an entry +curl -u "$AUTH" -X POST "$APP/myblog/entries" \ + -H "Content-Type: application/atom+xml;type=entry" \ + --data-binary @entry-request.xml + +# 3. Upload an image +curl -u "$AUTH" -X POST "$APP/myblog/resources/default" \ + -H "Content-Type: image/jpeg" -H "Slug: sunset" \ + --data-binary @sunset.jpg + +# 4. Create a category +curl -u "$AUTH" -X POST "$APP/myblog/categories" \ + -H "Content-Type: application/atom+xml;type=entry" \ + --data-binary @category-request.xml + +# 5. Change a template's mobile version (edit URI from GET $APP/myblog/templates) +curl -u "$AUTH" -X PUT "$APP/myblog/template/0e5d8b62" \ + -H "Content-Type: application/atom+xml;type=entry" \ + --data-binary @template-request.xml + +# 6. List pending comments, then approve one +curl -u "$AUTH" "$APP/myblog/comments?status=pending" +curl -u "$AUTH" -X PUT "$APP/myblog/comment/8f0e2a6c" \ + -H "Content-Type: application/atom+xml;type=entry" \ + --data-binary @comment-status-request.xml +---- + +== Schemas + +* Atom entries and feeds: the RELAX NG grammar in +https://www.rfc-editor.org/rfc/rfc4287#appendix-B[RFC 4287, Appendix B]. +* Service and category documents: the grammar in +https://www.rfc-editor.org/rfc/rfc5023#appendix-B[RFC 5023, Appendix B]. +* Roller's extension elements and `thr:in-reply-to`: `atompub/roller-atompub.rnc` +in Roller's `docs` directory: ++ +[source] +---- +include::atompub/roller-atompub.rnc[lines=17..-1] +---- diff --git a/docs/roller-user-guide.adoc b/docs/roller-user-guide.adoc index 8a0a33f2df..cb35bbb901 100644 --- a/docs/roller-user-guide.adoc +++ b/docs/roller-user-guide.adoc @@ -630,6 +630,12 @@ URL is _http://jroller.com/fredsblog_), then your parameters would be: * *BlogID*: fredsblog * *URL*: http://jroller.com/roller-services/xmlrpc +==== Managing templates, categories and comments with AtomPub + +Roller's AtomPub service can also manage a weblog's templates, categories and +comments. The AtomPub Guide (`roller-atompub-guide`) documents every URI, +permission and XML element, with examples. + == Working with comments Roller supports weblog comments, which are enabled by default. You can diff --git a/pom.xml b/pom.xml index 076721c560..396e9de79d 100644 --- a/pom.xml +++ b/pom.xml @@ -111,6 +111,8 @@ limitations under the License. **/*.adoc /docs/examples/plugins/pluginmodel/README.html /docs/examples/configs/wbloggar/wbloggar.txt + + /docs/atompub/examples/** /.asf.yaml /testing/roller-load-test.jmx