From 1caccae7a146db847dcacbaeb9b9ce59616f52bd Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 14:57:09 -0400 Subject: [PATCH 1/4] Manage templates, categories and comments over AtomPub Add three AtomPub collections to each weblog's workspace: - Templates (/{handle}/templates): create, read, update and delete a custom theme's templates, with the standard rendition as atom:content and the mobile rendition as roller:rendition. Weblog administrators only, and only while the weblog uses a custom theme and the site allows custom themes. An empty mobile rendition removes it. - Categories (/{handle}/categories): create, read, update and delete. A category in use is removed only with ?moveTo={id}. The entries collection lists categories inline and also references a new RFC 5023 category document at /{handle}/categories.atomcat. - Comments (/{handle}/comments): moderation only. List (filter by status or entry), change roller:status, delete. thr:in-reply-to (RFC 4685) links to the weblog entry. Move the template rules from the Templates and TemplateEdit pages into WeblogTemplateEditor so the UI and AtomPub enforce the same rules, and add WeblogManager.removeTemplateRendition() for mobile renditions. --- CHANGES.md | 11 + .../weblogger/business/WeblogManager.java | 7 + .../business/jpa/JPAWeblogManagerImpl.java | 14 + .../themes/TemplateRuleException.java | 73 +++ .../business/themes/WeblogTemplateEditor.java | 225 +++++++++ .../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 | 221 +++++++++ .../atomprotocol/CollectionSupport.java | 135 ++++++ .../atomprotocol/CommentCollection.java | 262 +++++++++++ .../atomprotocol/RollerAtomHandler.java | 87 +++- .../atomprotocol/RollerAtomService.java | 69 ++- .../atomprotocol/RollerAtomServlet.java | 5 + .../atomprotocol/TemplateCollection.java | 291 ++++++++++++ .../resources/ApplicationResources.properties | 1 + .../themes/WeblogTemplateEditorTest.java | 208 +++++++++ .../atomprotocol/AtomReaderTest.java | 38 ++ .../AtomSchemaValidationTest.java | 80 ++++ .../atomprotocol/AtomWriterTest.java | 19 + .../atomprotocol/RollerAtomHandlerTest.java | 48 ++ .../RollerAtomManagementTest.java | 440 ++++++++++++++++++ .../atomprotocol/RollerAtomProtocolTest.java | 6 +- docs/roller-user-guide.adoc | 42 ++ 30 files changed, 2462 insertions(+), 195 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/webservices/atomprotocol/RollerAtomManagementTest.java diff --git a/CHANGES.md b/CHANGES.md index 0d8517485..2077b26dc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,17 @@ ## 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. + ### 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 2b65f371b..0f862d1ea 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/JPAWeblogManagerImpl.java b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAWeblogManagerImpl.java index d4304b458..50e96eef7 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 000000000..20ed77c03 --- /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 000000000..2c8459656 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/business/themes/WeblogTemplateEditor.java @@ -0,0 +1,225 @@ +/* + * 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<>(); + 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/ui/struts2/editor/TemplateEdit.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/TemplateEdit.java index a1f95cebb..868b7f786 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 157e8f409..5346224de 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 02e04c33e..b0be73ccd 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 0e747f07c..6cae60914 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 96b000567..b9b7fa4b7 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 2b713b222..8e4bc1176 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 4940d7821..b8c7e38cc 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 a0651fedc..694fddefc 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 2c4c7eab1..c309dd61e 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 000000000..f0058b7d1 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CategoryCollection.java @@ -0,0 +1,221 @@ +/* + * 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.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.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; 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. Otherwise DELETE answers 409. + */ +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); + 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; + } + + private static void copyProperties(AtomEntry entry, WeblogCategory category) { + if (entry.getSummary() != null) { + category.setDescription(entry.getSummary().getValue()); + } + if (entry.getExtensions().containsKey("image")) { + category.setImage(StringUtils.trimToNull(entry.getExtension("image"))); + } + } + + /** 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"); + } + 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 000000000..47a6ae8c2 --- /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 000000000..8b865778c --- /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 2844bd1ef..c76ac2cc9 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 74094efba..4bd517c9b 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 4c765ec98..4dfc3755f 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 000000000..0ac405e2d --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/TemplateCollection.java @@ -0,0 +1,291 @@ +/* + * 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 java.util.Locale; + +import org.apache.commons.lang3.StringUtils; +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) { + template.setDescription(entry.getSummary().getValue()); + } + 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 8128a7b3c..7d9c6a35d 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 000000000..1a176e34b --- /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/webservices/atomprotocol/AtomReaderTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java index e5f4ce6a0..12e888afa 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 f5d6fdc26..3d4ca17c6 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 @@ -195,4 +195,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 36870dd8e..9f39933b5 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/RollerAtomHandlerTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerTest.java index 62fda8ede..220110fc6 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 000000000..2edaaf8b0 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomManagementTest.java @@ -0,0 +1,440 @@ +/* + * 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 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 0e791e43b..99411e602 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 @@ -199,9 +199,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/docs/roller-user-guide.adoc b/docs/roller-user-guide.adoc index 8a0a33f2d..87bdd7942 100644 --- a/docs/roller-user-guide.adoc +++ b/docs/roller-user-guide.adoc @@ -630,6 +630,48 @@ 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 (`/roller-services/app`) also lets a client manage +a weblog's templates, categories and comments. The service document lists +these collections in each weblog's workspace when you may use them. Roller's +own elements use the namespace `http://roller.apache.org/ns/atompub/1.0` +(prefix `roller` below). + +[cols="2,3,4",options="header"] +|=== +|URI (under `/roller-services/app`) |Who may use it |Methods +|`/{handle}/templates`, `/{handle}/template/{id}` +|Weblog administrators, when the weblog uses a custom theme and the site +allows custom themes +|GET, POST, PUT, DELETE +|`/{handle}/categories`, `/{handle}/category/{id}` +|Users who may post to the weblog +|GET, POST, PUT, DELETE (`?moveTo={id}` for a category in use) +|`/{handle}/categories.atomcat` +|Users who may post to the weblog +|GET (an RFC 5023 category document) +|`/{handle}/comments`, `/{handle}/comment/{id}` +|Users who may post to the weblog +|GET (`?status=`, `?entry=`), PUT (status only), DELETE +|=== + +* *Templates*: `atom:title` is the name, `atom:summary` the description and +`atom:content type="text"` the standard template. `roller:rendition +type="mobile"` holds the mobile template. `roller:action`, `roller:link`, +`roller:navbar`, `roller:hidden` and `roller:contentType` hold the other +settings; only custom templates can change their name, link and flags. A +PUT without a mobile rendition leaves the mobile template as it is; an +empty one removes it, so mobile visitors see the standard template. +Escape template markup as text. +* *Categories*: `atom:title` is the name, `atom:summary` the description and +`roller:image` the image URL. Deleting a category that entries use answers +409 unless `moveTo` names the category to move them to. +* *Comments*: `roller:status` is `approved`, `disapproved`, `spam` or +`pending`; a PUT changes only the status. `thr:in-reply-to` (RFC 4685) +points to the weblog entry. Approving a pending comment sends the same +notification as the Comments page. + == Working with comments Roller supports weblog comments, which are enabled by default. You can From 64bee5bffb3df178b98b489860a811f26e8453ba Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 15:15:48 -0400 Subject: [PATCH 2/4] Address PR #209 review findings - Refuse to delete a weblog's last category (409), as the Categories page does. - Make isWeblogCategoryInUse() null-safe for weblogs without a default category. - Test that deleting the default category with moveTo hands the default over, and that the servlet serves the category document. --- .../jpa/JPAWeblogEntryManagerImpl.java | 2 +- .../atomprotocol/CategoryCollection.java | 8 +++- .../RollerAtomManagementTest.java | 39 +++++++++++++++++++ .../atomprotocol/RollerAtomServletTest.java | 39 +++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) 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 def32afee..477179a3f 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/webservices/atomprotocol/CategoryCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CategoryCollection.java index f0058b7d1..2202c0d14 100644 --- 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 @@ -42,7 +42,9 @@ * *

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. Otherwise DELETE answers 409. + * 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 { @@ -144,6 +146,10 @@ public void deleteEntry(AtomRequest areq) throws AtomException { 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); 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 index 2edaaf8b0..ac7b5d642 100644 --- 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 @@ -291,6 +291,45 @@ public void categoryInUseIsRemovedOnlyWithMoveTo() throws Exception { 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 categoriesNeedPermissionOnTheWeblog() throws Exception { assertThrows(AtomNotAuthorizedException.class, () -> categories(author) 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 c603b6041..1e62f1b71 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 -> From 897fea02abd45bcf80867fdb2283f54ed2e2f8f3 Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 15:50:00 -0400 Subject: [PATCH 3/4] Apply the category editor's field rules to AtomPub - roller:image must be an http or https URL, as on the Categories page, which now uses the same check. The category wrapper gives templates only such URLs. - Category names and descriptions, and template names, links and descriptions, are limited to 255 characters, as in the editors. - Test that AtomPub tags use the shared tag character rules. --- .../business/themes/WeblogTemplateEditor.java | 6 +++ .../pojos/wrapper/WeblogCategoryWrapper.java | 7 ++- .../ui/struts2/editor/CategoryEdit.java | 5 ++ .../atomprotocol/CategoryCollection.java | 27 +++++++++-- .../atomprotocol/TemplateCollection.java | 10 +++- .../wrapper/WeblogCategoryWrapperTest.java | 46 +++++++++++++++++++ .../RollerAtomManagementTest.java | 41 +++++++++++++++++ .../atomprotocol/RollerAtomProtocolTest.java | 18 ++++++++ 8 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/org/apache/roller/weblogger/pojos/wrapper/WeblogCategoryWrapperTest.java 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 index 2c8459656..1631ca181 100644 --- 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 @@ -153,6 +153,12 @@ public WeblogTemplate create(Weblog weblog, ComponentType action, String name, 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) { 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 4f99ad57f..e2d62fccd 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 f12f6abf6..5913de08d 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/webservices/atomprotocol/CategoryCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/CategoryCollection.java index 2202c0d14..2a29a8db0 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -32,12 +33,14 @@ 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; roller:position + * 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 @@ -189,12 +192,27 @@ private AtomEntry createAtomEntry(Weblog weblog, WeblogCategory category) return entry; } - private static void copyProperties(AtomEntry entry, WeblogCategory category) { + /** 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) { - category.setDescription(entry.getSummary().getValue()); + String description = entry.getSummary().getValue(); + checkLength(description, "Category description"); + category.setDescription(description); } if (entry.getExtensions().containsKey("image")) { - category.setImage(StringUtils.trimToNull(entry.getExtension("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"); } } @@ -204,6 +222,7 @@ private static String validName(String name) throws AtomException { if (trimmed == null || !trimmed.equals(StringEscapeUtils.escapeHtml4(trimmed))) { throw CollectionSupport.badRequest("Invalid category name"); } + checkLength(trimmed, "Category name"); return trimmed; } 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 index 0ac405e2d..608465734 100644 --- 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 @@ -19,11 +19,13 @@ 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; @@ -184,7 +186,13 @@ private void applyProperties(Weblog weblog, WeblogTemplate template, AtomEntry e template.setName(name); } if (entry.getSummary() != null) { - template.setDescription(entry.getSummary().getValue()); + 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); 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 000000000..490fa88cf --- /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/RollerAtomManagementTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomManagementTest.java index ac7b5d642..4387a68a9 100644 --- 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 @@ -330,6 +330,47 @@ public void lastCategoryCannotBeRemoved() throws Exception { 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) 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 99411e602..da0a7f8a3 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(); From 1a3aa1f6d5c5891a439e76ac66aaaa832865b22c Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 16:05:55 -0400 Subject: [PATCH 4/4] Add an AtomPub reference guide (#211) * Add an AtomPub reference guide docs/roller-atompub-guide.adoc documents Roller's whole AtomPub interface: enabling and limits, BASIC and OAuth authentication, the service document, and the entries, media, category, template and comment resources (URIs, permissions, elements, status codes), with a curl session and an error table. It is built to PDF and shipped with the other guides. - docs/atompub/examples: XML examples included by the guide. Response examples are generated from AtomWriter by AtomPubDocExamplesTest, which fails when they no longer match (-Datompub.docs.update=true rewrites them). Request examples are hand-written and must parse. - docs/atompub/roller-atompub.rnc: RELAX NG grammar for the roller:* and thr:in-reply-to elements; the test validates every example against it and against the RFC 4287/5023 grammars. - Move the Jing helper into RelaxNgValidator for both schema tests. * Document the category image and length rules in the AtomPub guide --- CHANGES.md | 4 + .../atomprotocol/AtomPubDocExamplesTest.java | 487 +++++++++++++++++ .../AtomSchemaValidationTest.java | 29 +- .../atomprotocol/RelaxNgValidator.java | 72 +++ 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 | 42 +- pom.xml | 2 + 24 files changed, 1376 insertions(+), 67 deletions(-) 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 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 2077b26dc..26460a850 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,10 @@ 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/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 000000000..796f3a174 --- /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/AtomSchemaValidationTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java index 3d4ca17c6..c51f3cdde 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 { 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 000000000..4d8968ef0 --- /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/assembly-release/src/main/assembly/binary.xml b/assembly-release/src/main/assembly/binary.xml index ae75c95c5..85ea353fb 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 6991392fa..6fb2052a2 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 000000000..bbca40501 --- /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 000000000..67ef32542 --- /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 000000000..f3cfac6c0 --- /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 000000000..3f1b86ccb --- /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 000000000..e169b7094 --- /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 000000000..d74140ef2 --- /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 000000000..4b4e5ba4e --- /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 000000000..a9211a9aa --- /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 000000000..4fb2779ac --- /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 000000000..b513e743c --- /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 000000000..5427acd06 --- /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 000000000..f14322575 --- /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 000000000..b340626fe --- /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 000000000..5ba17d71e --- /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 000000000..2c7bb6f5e --- /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 000000000..bc7a04284 --- /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 87bdd7942..cb35bbb90 100644 --- a/docs/roller-user-guide.adoc +++ b/docs/roller-user-guide.adoc @@ -632,45 +632,9 @@ URL is _http://jroller.com/fredsblog_), then your parameters would be: ==== Managing templates, categories and comments with AtomPub -Roller's AtomPub service (`/roller-services/app`) also lets a client manage -a weblog's templates, categories and comments. The service document lists -these collections in each weblog's workspace when you may use them. Roller's -own elements use the namespace `http://roller.apache.org/ns/atompub/1.0` -(prefix `roller` below). - -[cols="2,3,4",options="header"] -|=== -|URI (under `/roller-services/app`) |Who may use it |Methods -|`/{handle}/templates`, `/{handle}/template/{id}` -|Weblog administrators, when the weblog uses a custom theme and the site -allows custom themes -|GET, POST, PUT, DELETE -|`/{handle}/categories`, `/{handle}/category/{id}` -|Users who may post to the weblog -|GET, POST, PUT, DELETE (`?moveTo={id}` for a category in use) -|`/{handle}/categories.atomcat` -|Users who may post to the weblog -|GET (an RFC 5023 category document) -|`/{handle}/comments`, `/{handle}/comment/{id}` -|Users who may post to the weblog -|GET (`?status=`, `?entry=`), PUT (status only), DELETE -|=== - -* *Templates*: `atom:title` is the name, `atom:summary` the description and -`atom:content type="text"` the standard template. `roller:rendition -type="mobile"` holds the mobile template. `roller:action`, `roller:link`, -`roller:navbar`, `roller:hidden` and `roller:contentType` hold the other -settings; only custom templates can change their name, link and flags. A -PUT without a mobile rendition leaves the mobile template as it is; an -empty one removes it, so mobile visitors see the standard template. -Escape template markup as text. -* *Categories*: `atom:title` is the name, `atom:summary` the description and -`roller:image` the image URL. Deleting a category that entries use answers -409 unless `moveTo` names the category to move them to. -* *Comments*: `roller:status` is `approved`, `disapproved`, `spam` or -`pending`; a PUT changes only the status. `thr:in-reply-to` (RFC 4685) -points to the weblog entry. Approving a pending comment sends the same -notification as the Comments page. +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 diff --git a/pom.xml b/pom.xml index 076721c56..396e9de79 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