From 166a1f7a289fda576ef2d27358f646a662ef2434 Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Thu, 13 Aug 2026 17:29:18 -0400 Subject: [PATCH 1/4] Replace ROME Propono AtomPub server with self-contained StAX implementation Reimplements the Atom Publishing Protocol (RFC 5023) server using only JDK StAX (javax.xml.stream) and plain DTOs -- no ROME, no Propono. This removes the rome-propono dependency that pinned the entire ROME stack to 1.19.0, freeing ROME-for-feeds to be upgraded independently. - New Roller-owned servlet (RollerAtomServlet) replaces Propono's AtomServlet; method dispatch, 201 Created/Location handling and media streaming are ported over. - New wire model (AtomEntry/AtomFeed/AtomContent/AtomLink/AtomPerson/ AtomCategory and the service-doc DTOs) with StAX AtomWriter/AtomReader. AtomReader disables DTDs and external entities (XXE-safe). - RollerAtomHandler/RollerAtomService/EntryCollection/MediaCollection keep their domain-mapping logic; only the ROME types they touched changed. Fixes a latent BASIC-auth bug that compared against the null instance field instead of the looked-up user's password. - Auth: keep BASIC + OAuth, drop WSSE (removes WSSEUtilities and the wsse choice from the admin config labels). - Adds unit tests for the reader/writer/DTOs/request, an integration test driving the create/retrieve/update/delete lifecycle against in-memory Derby, and schema-validation tests that check AtomWriter output against the RFC 4287/5023 RELAX NG schemas via Jing. The HTTP transport and BASIC auth over the wire require the Spring web context and are not exercised by the JUnit reactor; verify those with an over-the-wire exerciser (e.g. APE) against a deployed instance. --- app/pom.xml | 20 +- .../roller/weblogger/util/WSSEUtilities.java | 83 ------ .../atomprotocol/AtomCategories.java | 53 ++++ .../atomprotocol/AtomCategory.java | 53 ++++ .../atomprotocol/AtomCollection.java | 60 ++++ .../atomprotocol/AtomConstants.java | 42 +++ .../webservices/atomprotocol/AtomContent.java | 54 ++++ .../webservices/atomprotocol/AtomEntry.java | 141 +++++++++ .../atomprotocol/AtomException.java | 47 +++ .../webservices/atomprotocol/AtomFeed.java | 74 +++++ .../webservices/atomprotocol/AtomLink.java | 60 ++++ .../atomprotocol/AtomMediaResource.java | 63 ++++ ...y.java => AtomNotAuthorizedException.java} | 24 +- .../atomprotocol/AtomNotFoundException.java | 30 ++ .../webservices/atomprotocol/AtomPerson.java | 43 +++ .../webservices/atomprotocol/AtomReader.java | 146 +++++++++ .../webservices/atomprotocol/AtomRequest.java | 65 ++++ .../atomprotocol/AtomServiceDoc.java | 33 +++ .../atomprotocol/AtomWorkspace.java | 42 +++ .../webservices/atomprotocol/AtomWriter.java | 235 +++++++++++++++ .../atomprotocol/EntryCollection.java | 230 +++++++-------- .../atomprotocol/MediaCollection.java | 247 +++++++--------- .../atomprotocol/RollerAtomHandler.java | 131 ++------- .../atomprotocol/RollerAtomService.java | 76 ++--- .../atomprotocol/RollerAtomServlet.java | 216 ++++++++++++++ .../atomprotocol/package-info.java | 3 +- .../resources/ApplicationResources.properties | 2 +- .../ApplicationResources_ja.properties | 2 +- .../ApplicationResources_zh_CN.properties | 2 +- app/src/main/resources/propono.properties | 2 - app/src/main/webapp/WEB-INF/web.xml | 2 +- .../atomprotocol/AtomEntryTest.java | 55 ++++ .../atomprotocol/AtomExceptionTest.java | 59 ++++ .../atomprotocol/AtomReaderTest.java | 136 +++++++++ .../atomprotocol/AtomRequestTest.java | 97 ++++++ .../AtomSchemaValidationTest.java | 198 +++++++++++++ .../atomprotocol/AtomWriterTest.java | 255 ++++++++++++++++ .../atomprotocol/RollerAtomProtocolTest.java | 244 +++++++++++++++ .../test/resources/atompub/app-service.rnc | 182 ++++++++++++ app/src/test/resources/atompub/atom.rnc | 278 ++++++++++++++++++ 40 files changed, 3262 insertions(+), 523 deletions(-) delete mode 100644 app/src/main/java/org/apache/roller/weblogger/util/WSSEUtilities.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategory.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCollection.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomConstants.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomContent.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomException.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomFeed.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomLink.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomMediaResource.java rename app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/{RollerAtomHandlerFactory.java => AtomNotAuthorizedException.java} (60%) create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotFoundException.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java create mode 100644 app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java delete mode 100644 app/src/main/resources/propono.properties create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntryTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomExceptionTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequestTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java create mode 100644 app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java create mode 100644 app/src/test/resources/atompub/app-service.rnc create mode 100644 app/src/test/resources/atompub/atom.rnc diff --git a/app/pom.xml b/app/pom.xml index e40aef0d15..ce9373340a 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -54,7 +54,7 @@ limitations under the License. 3.4.0 3.5.2 1.0b3 - 1.19.0 + 1.19.0 2.0.16 5.3.39 5.8.14 @@ -522,11 +522,12 @@ limitations under the License. - + - com.rometools - rome-propono - ${rome.version} + commons-httpclient + commons-httpclient + 3.1 compile @@ -592,6 +593,15 @@ limitations under the License. test + + + com.thaiopensource + jing + 20091111 + test + + diff --git a/app/src/main/java/org/apache/roller/weblogger/util/WSSEUtilities.java b/app/src/main/java/org/apache/roller/weblogger/util/WSSEUtilities.java deleted file mode 100644 index e63032b442..0000000000 --- a/app/src/main/java/org/apache/roller/weblogger/util/WSSEUtilities.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2005, Dave Johnson - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.roller.weblogger.util; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.text.SimpleDateFormat; -import java.util.Date; - -import org.apache.commons.codec.binary.Base64; - -import static java.nio.charset.StandardCharsets.UTF_8; - -/** - * Utilties to support WSSE authentication. - * @author Dave Johnson - */ -public class WSSEUtilities { - public static synchronized String generateDigest( - byte[] nonce, byte[] created, byte[] password) { - String result = null; - try { - MessageDigest digester = MessageDigest.getInstance("SHA"); - digester.reset(); - digester.update(nonce); - digester.update(created); - digester.update(password); - byte[] digest = digester.digest(); - result = base64Encode(digest); - } - catch (NoSuchAlgorithmException e) { - result = null; - } - return result; - } - public static byte[] base64Decode(String value) throws IOException { - return Base64.decodeBase64(value.getBytes(UTF_8)); - } - public static String base64Encode(byte[] value) { - return new String(Base64.encodeBase64(value)); - } - public static String generateWSSEHeader(String userName, String password) - throws UnsupportedEncodingException { - - byte[] nonceBytes = Long.toString(new Date().getTime()).getBytes(); - String nonce = WSSEUtilities.base64Encode(nonceBytes); - - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); - String created = sdf.format(new Date()); - - String digest = WSSEUtilities.generateDigest( - nonceBytes, created.getBytes(UTF_8), password.getBytes(UTF_8)); - - StringBuilder header = new StringBuilder("UsernameToken Username=\""); - header.append(userName); - header.append("\", "); - header.append("PasswordDigest=\""); - header.append(digest); - header.append("\", "); - header.append("Nonce=\""); - header.append(nonce); - header.append("\", "); - header.append("Created=\""); - header.append(created); - header.append("\""); - return header.toString(); - } -} 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 new file mode 100644 index 0000000000..02e04c33ec --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java @@ -0,0 +1,53 @@ +/* +* 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.List; + +/** + * An APP categories element (app:categories) describing the categories a + * collection accepts. When {@code fixed} is true the listed categories are the + * only ones allowed. + */ +public class AtomCategories { + + private boolean fixed; + private String scheme; + private final List categories = new ArrayList<>(); + + public boolean isFixed() { + return fixed; + } + + public void setFixed(boolean fixed) { + this.fixed = fixed; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public List getCategories() { + return categories; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategory.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategory.java new file mode 100644 index 0000000000..3a59950009 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategory.java @@ -0,0 +1,53 @@ +/* +* 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; + +/** + * An atom:category element. A null {@code scheme} signifies a free-form tag; + * a scheme that matches the weblog category scheme signifies a weblog category. + */ +public class AtomCategory { + + private String term; + private String scheme; + private String label; + + public String getTerm() { + return term; + } + + public void setTerm(String term) { + this.term = term; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCollection.java new file mode 100644 index 0000000000..2b4507f20a --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCollection.java @@ -0,0 +1,60 @@ +/* +* 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.List; + +/** + * An APP collection (app:collection) within a workspace. + */ +public class AtomCollection { + + private String title; + private String href; + private List accepts = new ArrayList<>(); + private final List categories = new ArrayList<>(); + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } + + public List getAccepts() { + return accepts; + } + + public void setAccepts(List accepts) { + this.accepts = accepts; + } + + 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 new file mode 100644 index 0000000000..07cf3d4609 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomConstants.java @@ -0,0 +1,42 @@ +/* +* 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; + +/** + * Constants shared by the StAX-based AtomPub implementation. + */ +public final class AtomConstants { + + private AtomConstants() { + } + + /** Atom Syndication Format namespace (RFC 4287). */ + public static final String ATOM_NS = "http://www.w3.org/2005/Atom"; + + /** Atom Publishing Protocol namespace (RFC 5023). */ + public static final String APP_NS = "http://www.w3.org/2007/app"; + + /** Media type for an Atom entry. */ + public static final String ENTRY_MEDIA_TYPE = "application/atom+xml;type=entry"; + + /** Media type for an Atom feed/collection. */ + public static final String FEED_MEDIA_TYPE = "application/atom+xml;type=feed;charset=utf-8"; + + /** Media type for an APP service document. */ + public static final String SERVICE_MEDIA_TYPE = "application/atomsvc+xml;charset=utf-8"; +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomContent.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomContent.java new file mode 100644 index 0000000000..2740f97203 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomContent.java @@ -0,0 +1,54 @@ +/* +* 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; + +/** + * An Atom text/content construct (atom:content or atom:summary). For inline + * content {@code value} holds the text; for out-of-line media content + * {@code src} holds the source URI instead. + */ +public class AtomContent { + + private String type; + private String value; + private String src; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getSrc() { + return src; + } + + public void setSrc(String src) { + this.src = src; + } +} 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 new file mode 100644 index 0000000000..96b0005676 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java @@ -0,0 +1,141 @@ +/* +* 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; + +/** + * An atom:entry. The {@code draft} and {@code edited} fields carry the APP + * control extension (app:control/app:draft and app:edited). + */ +public class AtomEntry { + + private String id; + private String title; + private AtomContent content; + private AtomContent summary; + private Date published; + private Date updated; + private Date edited; + private boolean draft; + private List authors = new ArrayList<>(); + private List categories = new ArrayList<>(); + private List links = new ArrayList<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public AtomContent getContent() { + return content; + } + + public void setContent(AtomContent content) { + this.content = content; + } + + public AtomContent getSummary() { + return summary; + } + + public void setSummary(AtomContent summary) { + this.summary = summary; + } + + public Date getPublished() { + return published; + } + + public void setPublished(Date published) { + this.published = published; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } + + public Date getEdited() { + return edited; + } + + public void setEdited(Date edited) { + this.edited = edited; + } + + public boolean isDraft() { + return draft; + } + + public void setDraft(boolean draft) { + this.draft = draft; + } + + public List getAuthors() { + return authors; + } + + public void setAuthors(List authors) { + this.authors = authors; + } + + public List getCategories() { + return categories; + } + + public void setCategories(List categories) { + this.categories = categories; + } + + public List getLinks() { + return links; + } + + public void setLinks(List links) { + this.links = links; + } + + /** Return the href of the first link with the given rel, or null. */ + public String getLinkHref(String rel) { + if (links != null) { + for (AtomLink link : links) { + if (rel.equals(link.getRel())) { + return link.getHref(); + } + } + } + return null; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomException.java new file mode 100644 index 0000000000..25fb18cfa1 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomException.java @@ -0,0 +1,47 @@ +/* +* 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; + +/** + * Base exception for the AtomPub implementation. Carries the HTTP status code + * that the dispatcher servlet should return to the client. + */ +public class AtomException extends Exception { + + private final int status; + + public AtomException(String msg) { + this(msg, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, null); + } + + public AtomException(String msg, Throwable cause) { + this(msg, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, cause); + } + + protected AtomException(String msg, int status, Throwable cause) { + super(msg, cause); + this.status = status; + } + + /** HTTP status code to send to the client for this error. */ + public int getStatus() { + return status; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomFeed.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomFeed.java new file mode 100644 index 0000000000..d253f100cd --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomFeed.java @@ -0,0 +1,74 @@ +/* +* 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; + +/** + * An atom:feed used to represent an AtomPub collection. + */ +public class AtomFeed { + + private String id; + private String title; + private Date updated; + private List links = new ArrayList<>(); + private List entries = new ArrayList<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } + + public List getLinks() { + return links; + } + + public void setLinks(List links) { + this.links = links; + } + + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomLink.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomLink.java new file mode 100644 index 0000000000..2d17d5a918 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomLink.java @@ -0,0 +1,60 @@ +/* +* 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; + +/** + * An atom:link element. + */ +public class AtomLink { + + private String rel; + private String href; + private String type; + + public AtomLink() { + } + + public AtomLink(String rel, String href) { + this.rel = rel; + this.href = href; + } + + public String getRel() { + return rel; + } + + public void setRel(String rel) { + this.rel = rel; + } + + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomMediaResource.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomMediaResource.java new file mode 100644 index 0000000000..724ef30794 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomMediaResource.java @@ -0,0 +1,63 @@ +/* +* 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.InputStream; +import java.util.Date; + +/** + * Holder for the binary data of a media resource, returned when a client GETs + * the edit-media URI so the dispatcher servlet can stream the bytes. + */ +public class AtomMediaResource { + + private final String name; + private final long contentLength; + private final String contentType; + private final Date lastModified; + private final InputStream inputStream; + + public AtomMediaResource(String name, long contentLength, String contentType, + Date lastModified, InputStream inputStream) { + this.name = name; + this.contentLength = contentLength; + this.contentType = contentType; + this.lastModified = lastModified; + this.inputStream = inputStream; + } + + public String getName() { + return name; + } + + public long getContentLength() { + return contentLength; + } + + public String getContentType() { + return contentType; + } + + public Date getLastModified() { + return lastModified; + } + + public InputStream getInputStream() { + return inputStream; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java similarity index 60% rename from app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java rename to app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java index 4b3e1f3611..f568c5b13d 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java @@ -15,27 +15,17 @@ * 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 com.rometools.propono.atom.server.AtomHandlerFactory; -import com.rometools.propono.atom.server.AtomHandler; -import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** - * Extends {@link com.rometools.propono.atom.server.AtomHandlerFactory} to create and return - * {@link com.rometools.propono.atom.server.impl.FileBasedAtomHandler}. + * Thrown when an authenticated user is not permitted to perform the requested + * operation (HTTP 401, matching the previous ROME Propono behavior). */ -public class RollerAtomHandlerFactory extends AtomHandlerFactory { - - /** - * Create new AtomHandler. - */ - @Override - public AtomHandler newAtomHandler( - HttpServletRequest req, HttpServletResponse res) { - return new RollerAtomHandler(req, res); - } +public class AtomNotAuthorizedException extends AtomException { + + public AtomNotAuthorizedException(String msg) { + super(msg, HttpServletResponse.SC_UNAUTHORIZED, null); + } } - diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotFoundException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotFoundException.java new file mode 100644 index 0000000000..9f0a3e8670 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotFoundException.java @@ -0,0 +1,30 @@ +/* +* 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; + +/** + * Thrown when a requested resource cannot be found (HTTP 404). + */ +public class AtomNotFoundException extends AtomException { + + public AtomNotFoundException(String msg) { + super(msg, HttpServletResponse.SC_NOT_FOUND, 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 new file mode 100644 index 0000000000..2b713b222b --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java @@ -0,0 +1,43 @@ +/* +* 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; + +/** + * An Atom person construct (atom:author). + */ +public class AtomPerson { + + private String name; + private String email; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = 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 new file mode 100644 index 0000000000..f3bf21ce57 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java @@ -0,0 +1,146 @@ +/* +* 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 static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; + +import java.io.InputStream; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Date; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +/** + * Parses an incoming AtomPub request body (an atom:entry) into the wire model + * using the JDK StAX API. Replaces ROME's Atom parser. + * + *

DTD processing and external entities are disabled to protect against XXE + * attacks. + */ +public class AtomReader { + + private final XMLInputFactory factory; + + public AtomReader() { + factory = XMLInputFactory.newInstance(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE); + } + + static Date parseDate(String text) { + if (text == null || text.isBlank()) { + return null; + } + String trimmed = text.trim(); + try { + return Date.from(OffsetDateTime.parse(trimmed).toInstant()); + } catch (Exception ignored) { + try { + return Date.from(Instant.parse(trimmed)); + } catch (Exception ignored2) { + return null; + } + } + } + + /** + * Parse an atom:entry from the given stream. The author is intentionally not + * read; the server sets the entry's creator from the authenticated user. + */ + public AtomEntry parseEntry(InputStream in) throws AtomException { + XMLStreamReader r = null; + try { + r = factory.createXMLStreamReader(in, "UTF-8"); + AtomEntry entry = new AtomEntry(); + while (r.hasNext()) { + if (r.next() != XMLStreamConstants.START_ELEMENT) { + continue; + } + String ns = r.getNamespaceURI(); + String name = r.getLocalName(); + if (ATOM_NS.equals(ns)) { + switch (name) { + case "id": + entry.setId(r.getElementText()); + break; + case "title": + entry.setTitle(r.getElementText()); + break; + case "summary": + entry.setSummary(readContent(r)); + break; + case "content": + if (entry.getContent() == null) { + entry.setContent(readContent(r)); + } + break; + case "published": + entry.setPublished(parseDate(r.getElementText())); + break; + case "updated": + entry.setUpdated(parseDate(r.getElementText())); + break; + case "category": + entry.getCategories().add(readCategory(r)); + break; + default: + break; + } + } else if (APP_NS.equals(ns) && "draft".equals(name)) { + String value = r.getElementText(); + entry.setDraft(value != null && value.trim().equalsIgnoreCase("yes")); + } + } + return entry; + } catch (XMLStreamException ex) { + throw new AtomException("Error parsing Atom entry", ex); + } finally { + if (r != null) { + try { + r.close(); + } catch (XMLStreamException ignored) { + // nothing useful to do on close failure + } + } + } + } + + private AtomContent readContent(XMLStreamReader r) throws XMLStreamException { + AtomContent content = new AtomContent(); + content.setType(r.getAttributeValue(null, "type")); + String src = r.getAttributeValue(null, "src"); + content.setSrc(src); + if (src == null) { + content.setValue(r.getElementText()); + } + return content; + } + + private AtomCategory readCategory(XMLStreamReader r) { + AtomCategory cat = new AtomCategory(); + cat.setTerm(r.getAttributeValue(null, "term")); + cat.setScheme(r.getAttributeValue(null, "scheme")); + cat.setLabel(r.getAttributeValue(null, "label")); + return cat; + } +} 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 new file mode 100644 index 0000000000..cd6e620038 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java @@ -0,0 +1,65 @@ +/* +* 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.InputStream; + +import javax.servlet.http.HttpServletRequest; + +/** + * Lightweight wrapper around an {@link HttpServletRequest} for AtomPub handlers. + * The dispatcher servlet reads any request body once into a byte array and + * passes it here so handlers can read it (and so {@code getPathInfo()} never + * returns null for the service-document URI). + */ +public class AtomRequest { + + private static final byte[] EMPTY = new byte[0]; + + private final HttpServletRequest request; + private final byte[] body; + + public AtomRequest(HttpServletRequest request, byte[] body) { + this.request = request; + this.body = (body != null) ? body : EMPTY; + } + + /** Path info relative to the AtomPub servlet, never null ("" for the service doc). */ + public String getPathInfo() { + String pathInfo = request.getPathInfo(); + return (pathInfo != null) ? pathInfo : ""; + } + + public String getHeader(String name) { + return request.getHeader(name); + } + + public String getContentType() { + return request.getContentType(); + } + + /** A fresh stream over the buffered request body. */ + public InputStream getInputStream() { + return new ByteArrayInputStream(body); + } + + public HttpServletRequest getRequest() { + return request; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java new file mode 100644 index 0000000000..4640ac792a --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java @@ -0,0 +1,33 @@ +/* +* 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.List; + +/** + * An APP service document (app:service). + */ +public class AtomServiceDoc { + + private final List workspaces = new ArrayList<>(); + + public List getWorkspaces() { + return workspaces; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java new file mode 100644 index 0000000000..9ea26e9afc --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java @@ -0,0 +1,42 @@ +/* +* 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.List; + +/** + * An APP workspace (app:workspace) within a service document. + */ +public class AtomWorkspace { + + private String title; + private final List collections = new ArrayList<>(); + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public List getCollections() { + return collections; + } +} 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 new file mode 100644 index 0000000000..1366a883a0 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java @@ -0,0 +1,235 @@ +/* +* 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 static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; + +import java.io.OutputStream; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Date; + +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +/** + * Serializes the AtomPub wire model ({@link AtomEntry}, {@link AtomFeed}, + * {@link AtomServiceDoc}) to XML using the JDK StAX API. Replaces the ROME and + * Propono serialization the AtomPub server previously relied on. + * + *

Atom entries and feeds are written with the Atom namespace as the default + * namespace (so atom elements are unprefixed) and the APP namespace bound to the + * {@code app} prefix. Service documents use the reverse: APP as the default + * namespace and {@code atom} for atom elements. + */ +public class AtomWriter { + + private static final DateTimeFormatter RFC3339 = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC); + + private final XMLOutputFactory factory = XMLOutputFactory.newInstance(); + + static String formatDate(Date date) { + return date == null ? null : RFC3339.format(date.toInstant()); + } + + public void writeEntry(OutputStream out, AtomEntry entry) throws AtomException { + try { + XMLStreamWriter w = factory.createXMLStreamWriter(out, "UTF-8"); + w.writeStartDocument("UTF-8", "1.0"); + w.setDefaultNamespace(ATOM_NS); + w.setPrefix("app", APP_NS); + w.writeStartElement(ATOM_NS, "entry"); + w.writeDefaultNamespace(ATOM_NS); + w.writeNamespace("app", APP_NS); + writeEntryBody(w, entry); + w.writeEndElement(); + w.writeEndDocument(); + w.flush(); + w.close(); + } catch (XMLStreamException ex) { + throw new AtomException("Error serializing Atom entry", ex); + } + } + + public void writeFeed(OutputStream out, AtomFeed feed) throws AtomException { + try { + XMLStreamWriter w = factory.createXMLStreamWriter(out, "UTF-8"); + w.writeStartDocument("UTF-8", "1.0"); + w.setDefaultNamespace(ATOM_NS); + w.setPrefix("app", APP_NS); + w.writeStartElement(ATOM_NS, "feed"); + w.writeDefaultNamespace(ATOM_NS); + w.writeNamespace("app", APP_NS); + writeAtomText(w, "id", feed.getId()); + writeAtomText(w, "title", feed.getTitle()); + writeAtomText(w, "updated", formatDate(feed.getUpdated())); + for (AtomLink link : feed.getLinks()) { + writeLink(w, link); + } + for (AtomEntry entry : feed.getEntries()) { + w.writeStartElement(ATOM_NS, "entry"); + writeEntryBody(w, entry); + w.writeEndElement(); + } + w.writeEndElement(); + w.writeEndDocument(); + w.flush(); + w.close(); + } catch (XMLStreamException ex) { + throw new AtomException("Error serializing Atom feed", ex); + } + } + + public void writeServiceDoc(OutputStream out, AtomServiceDoc service) throws AtomException { + try { + XMLStreamWriter w = factory.createXMLStreamWriter(out, "UTF-8"); + w.writeStartDocument("UTF-8", "1.0"); + w.setDefaultNamespace(APP_NS); + w.setPrefix("atom", ATOM_NS); + w.writeStartElement(APP_NS, "service"); + w.writeDefaultNamespace(APP_NS); + w.writeNamespace("atom", ATOM_NS); + for (AtomWorkspace workspace : service.getWorkspaces()) { + w.writeStartElement(APP_NS, "workspace"); + writeAtomText(w, "title", workspace.getTitle()); + for (AtomCollection collection : workspace.getCollections()) { + w.writeStartElement(APP_NS, "collection"); + if (collection.getHref() != null) { + w.writeAttribute("href", collection.getHref()); + } + writeAtomText(w, "title", collection.getTitle()); + for (String accept : collection.getAccepts()) { + w.writeStartElement(APP_NS, "accept"); + w.writeCharacters(accept); + w.writeEndElement(); + } + for (AtomCategories cats : collection.getCategories()) { + w.writeStartElement(APP_NS, "categories"); + w.writeAttribute("fixed", cats.isFixed() ? "yes" : "no"); + if (cats.getScheme() != null) { + w.writeAttribute("scheme", cats.getScheme()); + } + for (AtomCategory cat : cats.getCategories()) { + writeCategory(w, cat); + } + w.writeEndElement(); + } + w.writeEndElement(); + } + w.writeEndElement(); + } + w.writeEndElement(); + w.writeEndDocument(); + w.flush(); + w.close(); + } catch (XMLStreamException ex) { + throw new AtomException("Error serializing service document", ex); + } + } + + private void writeEntryBody(XMLStreamWriter w, AtomEntry entry) throws XMLStreamException { + writeAtomText(w, "id", entry.getId()); + writeAtomText(w, "title", entry.getTitle()); + writeAtomText(w, "published", formatDate(entry.getPublished())); + writeAtomText(w, "updated", formatDate(entry.getUpdated())); + for (AtomPerson author : entry.getAuthors()) { + w.writeStartElement(ATOM_NS, "author"); + writeAtomText(w, "name", author.getName()); + writeAtomText(w, "email", author.getEmail()); + w.writeEndElement(); + } + for (AtomCategory cat : entry.getCategories()) { + writeCategory(w, cat); + } + if (entry.getSummary() != null) { + writeContent(w, "summary", entry.getSummary()); + } + if (entry.getContent() != null) { + writeContent(w, "content", entry.getContent()); + } + for (AtomLink link : entry.getLinks()) { + writeLink(w, link); + } + // APP control extension (RFC 5023) + w.writeStartElement(APP_NS, "control"); + w.writeStartElement(APP_NS, "draft"); + w.writeCharacters(entry.isDraft() ? "yes" : "no"); + w.writeEndElement(); + if (entry.getEdited() != null) { + w.writeStartElement(APP_NS, "edited"); + w.writeCharacters(formatDate(entry.getEdited())); + w.writeEndElement(); + } + w.writeEndElement(); + } + + private void writeContent(XMLStreamWriter w, String name, AtomContent content) + throws XMLStreamException { + w.writeStartElement(ATOM_NS, name); + if (content.getType() != null) { + w.writeAttribute("type", content.getType()); + } + if (content.getSrc() != null) { + w.writeAttribute("src", content.getSrc()); + } else if (content.getValue() != null) { + w.writeCharacters(content.getValue()); + } + w.writeEndElement(); + } + + private void writeLink(XMLStreamWriter w, AtomLink link) throws XMLStreamException { + w.writeStartElement(ATOM_NS, "link"); + if (link.getRel() != null) { + w.writeAttribute("rel", link.getRel()); + } + if (link.getHref() != null) { + w.writeAttribute("href", link.getHref()); + } + if (link.getType() != null) { + w.writeAttribute("type", link.getType()); + } + w.writeEndElement(); + } + + private void writeCategory(XMLStreamWriter w, AtomCategory cat) throws XMLStreamException { + w.writeStartElement(ATOM_NS, "category"); + if (cat.getTerm() != null) { + w.writeAttribute("term", cat.getTerm()); + } + if (cat.getScheme() != null) { + w.writeAttribute("scheme", cat.getScheme()); + } + if (cat.getLabel() != null) { + w.writeAttribute("label", cat.getLabel()); + } + w.writeEndElement(); + } + + private void writeAtomText(XMLStreamWriter w, String name, String text) + throws XMLStreamException { + if (text == null) { + return; + } + w.writeStartElement(ATOM_NS, name); + w.writeCharacters(text); + w.writeEndElement(); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java index e637ffc7d1..d65daaa825 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java @@ -1,13 +1,13 @@ /* * Copyright 2007 Sun Microsystems, Inc. All rights reserved. * Use is subject to license terms. - * + * * Licensed 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. @@ -18,21 +18,6 @@ package org.apache.roller.weblogger.webservices.atomprotocol; -import com.rometools.propono.atom.common.rome.AppModule; -import com.rometools.propono.atom.common.rome.AppModuleImpl; -import com.rometools.propono.atom.server.AtomException; -import com.rometools.propono.atom.server.AtomNotAuthorizedException; -import com.rometools.propono.atom.server.AtomNotFoundException; -import com.rometools.propono.atom.server.AtomRequest; -import com.rometools.rome.feed.atom.Category; -import com.rometools.rome.feed.atom.Content; -import com.rometools.rome.feed.atom.Entry; -import com.rometools.rome.feed.atom.Feed; -import com.rometools.rome.feed.atom.Link; -import com.rometools.rome.feed.atom.Person; -import com.rometools.rome.feed.module.Module; -import com.rometools.rome.feed.synd.SyndPerson; - import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collections; @@ -70,26 +55,26 @@ public class EntryCollection { private Weblogger roller; private User user; private static final int MAX_ENTRIES = 20; - private final String atomURL; - + private final String atomURL; + private static Log log = LogFactory.getFactory().getInstance(EntryCollection.class); - - + + public EntryCollection(User user, String atomURL) { this.user = user; this.atomURL = atomURL; this.roller = WebloggerFactory.getWeblogger(); } - - - public Entry postEntry(AtomRequest areq, Entry entry) throws AtomException { + + + public AtomEntry postEntry(AtomRequest areq, AtomEntry entry) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); try { // authenticated client posted a weblog entry String handle = pathInfo[0]; - Weblog website = + Weblog website = roller.getWeblogManager().getWeblogByHandle(handle); if (website == null) { throw new AtomNotFoundException("Cannot find weblog: " + handle); @@ -97,9 +82,9 @@ public Entry postEntry(AtomRequest areq, Entry entry) throws AtomException { if (!RollerAtomHandler.canEdit(user, website)) { throw new AtomNotAuthorizedException("Not authorized to access website: " + handle); } - + RollerAtomHandler.oneSecondThrottle(); - + // Save it and commit it WeblogEntryManager mgr = roller.getWeblogEntryManager(); WeblogEntry rollerEntry = new WeblogEntry(); @@ -116,13 +101,10 @@ public Entry postEntry(AtomRequest areq, Entry entry) throws AtomException { } rollerEntry = mgr.getWeblogEntry(rollerEntry.getId()); - Entry newEntry = createAtomEntry(rollerEntry); - for (Object objLink : newEntry.getOtherLinks()) { - Link link = (Link) objLink; - if ("edit".equals(link.getRel())) { - log.debug("Exiting"); - return createAtomEntry(rollerEntry); - } + AtomEntry newEntry = createAtomEntry(rollerEntry); + if (newEntry.getLinkHref("edit") != null) { + log.debug("Exiting"); + return newEntry; } log.error("ERROR: no edit link found in saved media entry"); log.debug("Exiting via exception"); @@ -132,9 +114,9 @@ public Entry postEntry(AtomRequest areq, Entry entry) throws AtomException { } throw new AtomException("Posting entry"); } - - - public Entry getEntry(AtomRequest areq) throws AtomException { + + + public AtomEntry getEntry(AtomRequest areq) throws AtomException { try { String entryid = Utilities.stringToStringArray(areq.getPathInfo(),"/")[2]; WeblogEntry entry = roller.getWeblogEntryManager().getWeblogEntry(entryid); @@ -150,9 +132,9 @@ public Entry getEntry(AtomRequest areq) throws AtomException { throw new AtomException("ERROR fetching entry", ex); } } - - - public Feed getCollection(AtomRequest areq) throws AtomException { + + + public AtomFeed getCollection(AtomRequest areq) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); try { @@ -165,7 +147,7 @@ public Feed getCollection(AtomRequest areq) throws AtomException { } catch (Exception e) { log.warn("Unparsable range: " + pathInfo[2]); } - } + } String handle = pathInfo[0]; String absUrl = WebloggerRuntimeConfig.getAbsoluteContextURL(); Weblog website = roller.getWeblogManager().getWeblogByHandle(handle); @@ -181,37 +163,37 @@ public Feed getCollection(AtomRequest areq) throws AtomException { wesc.setOffset(start); wesc.setMaxResults(max + 1); List entries = roller.getWeblogEntryManager().getWeblogEntries(wesc); - Feed feed = new Feed(); + AtomFeed feed = new AtomFeed(); feed.setId(atomURL +"/"+website.getHandle() + "/entries/" + start); feed.setTitle(website.getName()); - Link link = new Link(); + List links = new ArrayList<>(); + AtomLink link = new AtomLink(); link.setHref(absUrl + "/" + website.getHandle()); link.setRel("alternate"); link.setType("text/html"); - feed.setAlternateLinks(Collections.singletonList(link)); + links.add(link); - List atomEntries = new ArrayList<>(); + List atomEntries = new ArrayList<>(); int count = 0; for (WeblogEntry rollerEntry : entries) { if (count++ >= MAX_ENTRIES) { break; } - Entry entry = createAtomEntry(rollerEntry); + AtomEntry entry = createAtomEntry(rollerEntry); atomEntries.add(entry); if (count == 1) { // first entry is most recent feed.setUpdated(entry.getUpdated()); } } - List links = new ArrayList<>(); if (entries.size() > max) { // add next link int nextOffset = start + max; String url = atomURL+"/" + website.getHandle() + "/entries/" + nextOffset; - Link nextLink = new Link(); + AtomLink nextLink = new AtomLink(); nextLink.setRel("next"); nextLink.setHref(url); links.add(nextLink); @@ -221,27 +203,25 @@ public Feed getCollection(AtomRequest areq) throws AtomException { int prevOffset = start > max ? start - max : 0; String url = atomURL+"/" +website.getHandle() + "/entries/" + prevOffset; - Link prevLink = new Link(); + AtomLink prevLink = new AtomLink(); prevLink.setRel("previous"); prevLink.setHref(url); links.add(prevLink); } - if (!links.isEmpty()) { - feed.setOtherLinks(links); - } + feed.setLinks(links); // Use collection URI as id feed.setEntries(atomEntries); - + log.debug("Exiting"); return feed; - + } catch (WebloggerException re) { throw new AtomException("Getting entry collection"); } } - - - public void putEntry(AtomRequest areq, Entry entry) throws AtomException { + + + public void putEntry(AtomRequest areq, AtomEntry entry) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); try { @@ -252,18 +232,18 @@ public void putEntry(AtomRequest areq, Entry entry) throws AtomException { roller.getWeblogEntryManager().getWeblogEntry(pathInfo[2]); if (rollerEntry == null) { throw new AtomNotFoundException( - "Cannot find specified entry/resource"); + "Cannot find specified entry/resource"); } if (RollerAtomHandler.canEdit(user, rollerEntry)) { - + RollerAtomHandler.oneSecondThrottle(); - + WeblogEntryManager mgr = roller.getWeblogEntryManager(); copyToRollerEntry(entry, rollerEntry); rollerEntry.setUpdateTime(new Timestamp(new Date().getTime())); mgr.saveWeblogEntry(rollerEntry); roller.flush(); - + CacheManager.invalidate(rollerEntry.getWebsite()); if (rollerEntry.isPublished()) { roller.getIndexManager().addEntryReIndexOperation(rollerEntry); @@ -274,13 +254,13 @@ public void putEntry(AtomRequest areq, Entry entry) throws AtomException { throw new AtomNotAuthorizedException("ERROR not authorized to update entry"); } throw new AtomNotFoundException("Cannot find specified entry/resource"); - + } catch (WebloggerException re) { throw new AtomException("Updating entry"); } } - - + + public void deleteEntry(AtomRequest areq) throws AtomException { try { String[] pathInfo = StringUtils.split(areq.getPathInfo(), "/"); @@ -297,98 +277,89 @@ public void deleteEntry(AtomRequest areq) throws AtomException { roller.flush(); return; } - log.debug("Not authorized to delete entry"); - log.debug("Exiting via exception"); - + log.debug("Not authorized to delete entry"); + log.debug("Exiting via exception"); + } catch (WebloggerException ex) { throw new AtomException("ERROR deleting entry",ex); } throw new AtomNotAuthorizedException("Not authorized to delete entry"); } - + /** - * Create a Rome Atom entry based on a Weblogger entry. + * Create an Atom entry based on a Weblogger entry. * Content is escaped. * Link is stored as rel=alternate link. */ - private Entry createAtomEntry(WeblogEntry entry) { - Entry atomEntry = new Entry(); - + private AtomEntry createAtomEntry(WeblogEntry entry) { + AtomEntry atomEntry = new AtomEntry(); + atomEntry.setId( entry.getPermalink()); atomEntry.setTitle( entry.getTitle()); atomEntry.setPublished( entry.getPubTime()); atomEntry.setUpdated( entry.getUpdateTime()); - - Content content = new Content(); - content.setType(Content.HTML); + + AtomContent content = new AtomContent(); + content.setType("html"); content.setValue(entry.getText()); - List contents = new ArrayList<>(); - contents.add(content); - - atomEntry.setContents(contents); - + atomEntry.setContent(content); + if (StringUtils.isNotEmpty(entry.getSummary())) { - Content summary = new Content(); - summary.setType(Content.HTML); + AtomContent summary = new AtomContent(); + summary.setType("html"); summary.setValue(entry.getSummary()); atomEntry.setSummary(summary); } - + User creator = entry.getCreator(); - SyndPerson author = new Person(); + AtomPerson author = new AtomPerson(); author.setName( creator.getUserName()); author.setEmail( creator.getEmailAddress()); - atomEntry.setAuthors(Collections.singletonList(author)); - + atomEntry.setAuthors(new ArrayList<>(Collections.singletonList(author))); + // Add Atom category for Weblogger category, using category scheme - List categories = new ArrayList<>(); - Category atomCat = new Category(); + List categories = new ArrayList<>(); + AtomCategory atomCat = new AtomCategory(); atomCat.setScheme(RollerAtomService.getWeblogCategoryScheme(entry.getWebsite())); atomCat.setTerm(entry.getCategory().getName()); categories.add(atomCat); - + // Add Atom categories for each Weblogger tag with null scheme Set tmp = new TreeSet<>(new WeblogEntryTagComparator()); tmp.addAll(entry.getTags()); for (WeblogEntryTag tag : tmp) { - Category newcat = new Category(); + AtomCategory newcat = new AtomCategory(); newcat.setTerm(tag.getName()); categories.add(newcat); - } + } atomEntry.setCategories(categories); - - Link altlink = new Link(); + + List links = new ArrayList<>(); + AtomLink altlink = new AtomLink(); altlink.setRel("alternate"); altlink.setHref(entry.getPermalink()); - List altlinks = new ArrayList<>(); - altlinks.add(altlink); - atomEntry.setAlternateLinks(altlinks); - - Link editlink = new Link(); + links.add(altlink); + + AtomLink editlink = new AtomLink(); editlink.setRel("edit"); editlink.setHref( atomURL +"/"+entry.getWebsite().getHandle() + "/entry/" + entry.getId()); - List otherlinks = new ArrayList<>(); - otherlinks.add(editlink); - atomEntry.setOtherLinks(otherlinks); - - List modules = new ArrayList<>(); - AppModule app = new AppModuleImpl(); - app.setDraft(!WeblogEntry.PubStatus.PUBLISHED.equals(entry.getStatus())); - app.setEdited(entry.getUpdateTime()); - modules.add(app); - atomEntry.setModules(modules); - + links.add(editlink); + atomEntry.setLinks(links); + + atomEntry.setDraft(!WeblogEntry.PubStatus.PUBLISHED.equals(entry.getStatus())); + atomEntry.setEdited(entry.getUpdateTime()); + return atomEntry; } - + /** - * Copy fields from ROME entry to Weblogger entry. + * Copy fields from Atom entry to Weblogger entry. */ - private void copyToRollerEntry(Entry entry, WeblogEntry rollerEntry) throws WebloggerException { - + private void copyToRollerEntry(AtomEntry entry, WeblogEntry rollerEntry) throws WebloggerException { + Timestamp current = new Timestamp(System.currentTimeMillis()); Timestamp pubTime = current; Timestamp updateTime = current; @@ -399,31 +370,28 @@ private void copyToRollerEntry(Entry entry, WeblogEntry rollerEntry) throws Webl updateTime = new Timestamp( entry.getUpdated().getTime() ); } rollerEntry.setTitle(entry.getTitle()); - if (entry.getContents() != null && !entry.getContents().isEmpty()) { - Content content = entry.getContents().get(0); - rollerEntry.setText(content.getValue()); + if (entry.getContent() != null) { + rollerEntry.setText(entry.getContent().getValue()); } if (entry.getSummary() != null) { rollerEntry.setSummary(entry.getSummary().getValue()); } rollerEntry.setPubTime(pubTime); rollerEntry.setUpdateTime(updateTime); - - AppModule control = - (AppModule)entry.getModule(AppModule.URI); - if (control!=null && control.getDraft()) { + + if (entry.isDraft()) { rollerEntry.setStatus(PubStatus.DRAFT); } else { rollerEntry.setStatus(PubStatus.PUBLISHED); } - + // Process incoming categories: // Atom categories with weblog-level scheme are Weblogger categories. // Atom supports multiple cats, but Weblogger supports one/entry // so here we take accept the first category that exists. - List categories = entry.getCategories(); + List categories = entry.getCategories(); if (categories != null && !categories.isEmpty()) { - for (Category cat : categories) { + for (AtomCategory cat : categories) { if (cat.getScheme() != null && cat.getScheme().equals( RollerAtomService.getWeblogCategoryScheme(rollerEntry.getWebsite()))) { String catString = cat.getTerm(); @@ -444,31 +412,31 @@ private void copyToRollerEntry(Entry entry, WeblogEntry rollerEntry) throws Webl // Didn't find a category? Fall back to the default Blogger API category. rollerEntry.setCategory(rollerEntry.getWebsite().getBloggerCategory()); } - + // Now process incoming categories that are tags: // Atom categories with no scheme are considered tags. String tags = ""; StringBuilder buff = new StringBuilder(); if (categories != null && !categories.isEmpty()) { - for (Category cat : categories) { + for (AtomCategory cat : categories) { if (cat.getScheme() == null) { buff.append(" ").append(cat.getTerm()); - } + } } tags = buff.toString(); } - rollerEntry.setTagsAsString(tags); + rollerEntry.setTagsAsString(tags); } private void reindexEntry(WeblogEntry entry) throws WebloggerException { IndexManager manager = roller.getIndexManager(); - + // TODO: figure out what's up here and at WeblogEntryFormAction line 696 //manager.removeEntryIndexOperation(entry); - + // if published, index the entry if (entry.isPublished()) { manager.addEntryReIndexOperation(entry); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java index e7e963482e..e31190bdc8 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java @@ -1,13 +1,13 @@ /* * Copyright 2007 Sun Microsystems, Inc. All rights reserved. * Use is subject to license terms. - * + * * Licensed 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. @@ -17,17 +17,6 @@ package org.apache.roller.weblogger.webservices.atomprotocol; -import com.rometools.propono.atom.common.rome.AppModule; -import com.rometools.propono.atom.common.rome.AppModuleImpl; -import com.rometools.propono.atom.server.AtomException; -import com.rometools.propono.atom.server.AtomMediaResource; -import com.rometools.propono.atom.server.AtomNotAuthorizedException; -import com.rometools.propono.atom.server.AtomNotFoundException; -import com.rometools.propono.atom.server.AtomRequest; -import com.rometools.rome.feed.atom.Content; -import com.rometools.rome.feed.atom.Entry; -import com.rometools.rome.feed.atom.Feed; -import com.rometools.rome.feed.atom.Link; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -35,7 +24,6 @@ import java.io.InputStream; import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; @@ -70,20 +58,20 @@ public class MediaCollection { private Weblogger roller; private User user; private static final int MAX_ENTRIES = 20; - private final String atomURL; - + private final String atomURL; + private static Log log = LogFactory.getFactory().getInstance(EntryCollection.class); - - + + public MediaCollection(User user, String atomURL) { this.user = user; this.atomURL = atomURL; this.roller = WebloggerFactory.getWeblogger(); - } - - - public Entry postMedia(AtomRequest areq, Entry entry) throws AtomException { + } + + + public AtomEntry postMedia(AtomRequest areq, AtomEntry entry) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); @@ -91,11 +79,11 @@ public Entry postMedia(AtomRequest areq, Entry entry) throws AtomException { // get incoming slug from HTTP header String slug = areq.getHeader("Slug"); - Content content = entry.getContents().get(0); + AtomContent content = entry.getContent(); String contentType = content.getType(); InputStream is = areq.getInputStream(); String title = entry.getTitle() != null ? entry.getTitle() : slug; - + // authenticated client posted a weblog entry File tempFile = null; String handle = pathInfo[0]; @@ -106,14 +94,14 @@ public Entry postMedia(AtomRequest areq, Entry entry) throws AtomException { } if (pathInfo.length > 1) { // Save to temp file - String fileName = createFileName(website, + String fileName = createFileName(website, (slug != null) ? slug : Utilities.replaceNonAlphanumeric(title,' '), contentType); try { tempFile = File.createTempFile(fileName, "tmp"); FileOutputStream fos = new FileOutputStream(tempFile); Utilities.copyInputToOutput(is, fos); fos.close(); - + // Parse pathinfo to determine file path String path = filePathFromPathInfo(pathInfo); String justPath = path; @@ -147,20 +135,17 @@ public Entry postMedia(AtomRequest areq, Entry entry) throws AtomException { } roller.flush(); - + fis.close(); - + MediaFile stored = fileMgr.getMediaFile(mf.getId()); - Entry mediaEntry = createAtomResourceEntry(website, stored); - for (Object objLink : mediaEntry.getOtherLinks()) { - Link link = (Link) objLink; - if ("edit".equals(link.getRel())) { - log.debug("Exiting"); - return mediaEntry; - } + AtomEntry mediaEntry = createAtomResourceEntry(website, stored); + if (mediaEntry.getLinkHref("edit") != null) { + log.debug("Exiting"); + return mediaEntry; } log.error("ERROR: no edit link found in saved media entry"); - + } catch (FileIOException fie) { throw new AtomException( "File upload disabled, over-quota or other error", fie); @@ -171,16 +156,16 @@ public Entry postMedia(AtomRequest areq, Entry entry) throws AtomException { } } throw new AtomException("Error saving media entry"); - + } catch (WebloggerException re) { throw new AtomException("Posting media", re); } catch (IOException ioe) { throw new AtomException("Posting media", ioe); } } - - - public Entry getEntry(AtomRequest areq) throws AtomException { + + + public AtomEntry getEntry(AtomRequest areq) throws AtomException { try { String[] pathInfo = Utilities.stringToStringArray(areq.getPathInfo(), "/"); @@ -196,14 +181,14 @@ public Entry getEntry(AtomRequest areq) throws AtomException { if (mf != null) { return createAtomResourceEntry(website, mf); } - + } catch (WebloggerException ex) { throw new AtomException("ERROR fetching entry",ex); } throw new AtomNotFoundException("ERROR resource not found"); } - - + + public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); @@ -216,13 +201,14 @@ public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException throw new AtomNotAuthorizedException("Not authorized to edit weblog: " + handle); } if (pathInfo.length > 1) { - try { + try { // Parse pathinfo to determine file path String filePath = filePathFromPathInfo(pathInfo); MediaFile mf = fmgr.getMediaFileByOriginalPath(website, filePath); return new AtomMediaResource( mf.getName(), mf.getLength(), + Utilities.getContentTypeFromFileName(mf.getName()), new Date(mf.getLastModified()), mf.getInputStream()); } catch (Exception e) { @@ -231,14 +217,14 @@ public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException } } throw new AtomException("Incorrect path information"); - + } catch (WebloggerException re) { throw new AtomException("Posting media"); } } - - - public Feed getCollection(AtomRequest areq) throws AtomException { + + + public AtomFeed getCollection(AtomRequest areq) throws AtomException { log.debug("Entering"); String[] rawPathInfo = StringUtils.split(areq.getPathInfo(),"/"); try { @@ -256,7 +242,7 @@ public Feed getCollection(AtomRequest areq) throws AtomException { if (!path.isEmpty()) { path = path + File.separator; } - + String handle = pathInfo[0]; String absUrl = WebloggerRuntimeConfig.getAbsoluteContextURL(); Weblog website = roller.getWeblogManager().getWeblogByHandle(handle); @@ -267,16 +253,17 @@ public Feed getCollection(AtomRequest areq) throws AtomException { throw new AtomNotAuthorizedException("Not authorized to access website"); } - Feed feed = new Feed(); + AtomFeed feed = new AtomFeed(); feed.setId(atomURL - +"/"+website.getHandle() + "/resources/" + path + start); + +"/"+website.getHandle() + "/resources/" + path + start); feed.setTitle(website.getName()); - Link link = new Link(); + List links = new ArrayList<>(); + AtomLink link = new AtomLink(); link.setHref(absUrl + "/" + website.getHandle()); link.setRel("alternate"); link.setType("text/html"); - feed.setAlternateLinks(Collections.singletonList(link)); + links.add(link); MediaFileManager fmgr = roller.getMediaFileManager(); MediaFileDirectory dir; @@ -305,7 +292,7 @@ else if (f1.getLastModified() == f2.getLastModified()) { } } }); - + if (files != null && start < files.size()) { for (MediaFile mf : files) { sortedSet.add(mf); @@ -313,9 +300,9 @@ else if (f1.getLastModified() == f2.getLastModified()) { int count = 0; MediaFile[] sortedResources = sortedSet.toArray(MediaFile[]::new); - List atomEntries = new ArrayList<>(); + List atomEntries = new ArrayList<>(); for (int i=start; i<(start + max) && i<(sortedResources.length); i++) { - Entry entry = createAtomResourceEntry(website, sortedResources[i]); + AtomEntry entry = createAtomResourceEntry(website, sortedResources[i]); atomEntries.add(entry); if (count == 0) { // first entry is most recent @@ -324,28 +311,26 @@ else if (f1.getLastModified() == f2.getLastModified()) { count++; } - List otherLinks = new ArrayList<>(); if (start + count < files.size()) { // add next link int nextOffset = start + max; String url = atomURL +"/"+ website.getHandle() + "/resources/" + path + nextOffset; - Link nextLink = new Link(); + AtomLink nextLink = new AtomLink(); nextLink.setRel("next"); nextLink.setHref(url); - otherLinks.add(nextLink); + links.add(nextLink); } if (start > 0) { // add previous link int prevOffset = start > max ? start - max : 0; String url = atomURL +"/"+website.getHandle() + "/resources/" + path + prevOffset; - Link prevLink = new Link(); + AtomLink prevLink = new AtomLink(); prevLink.setRel("previous"); prevLink.setHref(url); - otherLinks.add(prevLink); + links.add(prevLink); } - feed.setOtherLinks(otherLinks); feed.setEntries(atomEntries); log.debug("Collection contains: " + count); @@ -353,23 +338,24 @@ else if (f1.getLastModified() == f2.getLastModified()) { } else { log.debug("Returning empty collection"); } - + + feed.setLinks(links); log.debug("Exiting"); return feed; - + } catch (WebloggerException re) { throw new AtomException("Getting resource collection", re); } } - - + + public void putMedia(AtomRequest areq) throws AtomException { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); String contentType = areq.getContentType(); try { InputStream is = areq.getInputStream(); - + // authenticated client posted a weblog entry File tempFile = null; String handle = pathInfo[0]; @@ -386,12 +372,12 @@ public void putMedia(AtomRequest areq) throws AtomException { FileOutputStream fos = new FileOutputStream(tempFile); Utilities.copyInputToOutput(is, fos); fos.close(); - + FileInputStream fis = new FileInputStream(tempFile); // Parse pathinfo to determine file path String path = filePathFromPathInfo(pathInfo); - + // Attempt to load file, to ensure it exists MediaFile mf = fmgr.getMediaFileByPath(website, path); mf.setContentType(contentType); @@ -403,7 +389,7 @@ public void putMedia(AtomRequest areq) throws AtomException { roller.flush(); fis.close(); - + log.debug("Exiting"); return; @@ -420,15 +406,13 @@ public void putMedia(AtomRequest areq) throws AtomException { } } throw new AtomException("Incorrect path information"); - + } catch (WebloggerException re) { throw new AtomException("Posting media"); - } catch (IOException ioe) { - throw new AtomException("Posting media", ioe); } } - - + + public void deleteEntry(AtomRequest areq) throws AtomException { try { String[] pathInfo = StringUtils.split(areq.getPathInfo(), "/"); @@ -446,23 +430,23 @@ public void deleteEntry(AtomRequest areq) throws AtomException { fmgr.removeMediaFile(website, mf); log.debug("Deleted media entry: " + fileName); return; - + } catch (Exception e) { String msg = "ERROR deleting media entry"; log.error(msg, e); throw new AtomException(msg); } } - log.debug("Not authorized to delete media entry"); - log.debug("Exiting via exception"); + log.debug("Not authorized to delete media entry"); + log.debug("Exiting via exception"); } catch (WebloggerException ex) { throw new AtomException("ERROR deleting media entry",ex); } throw new AtomNotAuthorizedException("Not authorized to delete entry"); } - - + + private String filePathFromPathInfo(String[] pathInfo) { String path = null; if (pathInfo.length > 2) { @@ -479,76 +463,69 @@ private String filePathFromPathInfo(String[] pathInfo) { } return path; } - - private Entry createAtomResourceEntry(Weblog website, MediaFile file) { + + private AtomEntry createAtomResourceEntry(Weblog website, MediaFile file) { String filePath = file.getPath().endsWith("/") ? file.getPath() + file.getName() : file.getPath() + "/" + file.getName(); - String editURI = + String editURI = atomURL+"/"+website.getHandle() + "/resource/" + filePath + ".media-link"; - String editMediaURI = + String editMediaURI = atomURL+"/"+ website.getHandle() + "/resource/" + filePath; String contentType = Utilities.getContentTypeFromFileName(file.getName()); - - Entry entry = new Entry(); + + AtomEntry entry = new AtomEntry(); entry.setId(editMediaURI); entry.setTitle(file.getName()); entry.setUpdated(new Date(file.getLastModified())); - - Link altlink = new Link(); + + List links = new ArrayList<>(); + AtomLink altlink = new AtomLink(); altlink.setRel("alternate"); altlink.setHref(file.getPermalink()); - List altlinks = new ArrayList<>(); - altlinks.add(altlink); - entry.setAlternateLinks(altlinks); - - List otherlinks = new ArrayList<>(); - entry.setOtherLinks(otherlinks); - Link editlink = new Link(); - editlink.setRel("edit"); - editlink.setHref(editURI); - otherlinks.add(editlink); - Link editMedialink = new Link(); - editMedialink.setRel("edit-media"); - editMedialink.setHref(editMediaURI); - otherlinks.add(editMedialink); - - Content content = new Content(); + links.add(altlink); + + AtomLink editlink = new AtomLink(); + editlink.setRel("edit"); + editlink.setHref(editURI); + links.add(editlink); + + AtomLink editMedialink = new AtomLink(); + editMedialink.setRel("edit-media"); + editMedialink.setHref(editMediaURI); + links.add(editMedialink); + entry.setLinks(links); + + AtomContent content = new AtomContent(); content.setSrc(file.getPermalink()); content.setType(contentType); - List contents = new ArrayList<>(); - contents.add(content); - entry.setContents(contents); - - List modules = new ArrayList<>(); - AppModule app = new AppModuleImpl(); - app.setDraft(false); - app.setEdited(entry.getUpdated()); - modules.add(app); - entry.setModules(modules); - + entry.setContent(content); + + entry.setDraft(false); + entry.setEdited(entry.getUpdated()); + return entry; } - - + + /** - * Creates a file name for a file based on a weblog, title string and a - * content-type. - * + * Creates a file name for a file based on a weblog, title string and a + * content-type. + * * @param weblog Weblog for which file name is being created * @param title Title to be used as basis for file name (or null) * @param contentType Content type of file (must not be null) - * - * If a title is specified, the method will apply the same create-anchor + * + * If a title is specified, the method will apply the same create-anchor * logic we use for weblog entries to create a file name based on the title. * - * If title is null, the base file name will be the weblog handle plus a - * YYYYMMDDHHSS timestamp. + * If title is null, the base file name will be the weblog handle plus a + * YYYYMMDDHHSS timestamp. * * The extension will be formed by using the part of content type that - * comes after he slash. + * comes after he slash. * * For example: * weblog.handle = "daveblog" @@ -563,23 +540,23 @@ private Entry createAtomResourceEntry(Weblog website, MediaFile file) { * Might result in daveblog-200608201034.jpg */ private String createFileName(Weblog weblog, String title, String contentType) { - + if (weblog == null) { throw new IllegalArgumentException("weblog cannot be null"); } if (contentType == null) { throw new IllegalArgumentException("contentType cannot be null"); } - + String fileName; - + // Determine the extension based on the contentType. This is a hack. - // The info we need to map from contentType to file extension is in - // JRE/lib/content-type.properties, but Java Activation doesn't provide + // The info we need to map from contentType to file extension is in + // JRE/lib/content-type.properties, but Java Activation doesn't provide // a way to do a reverse mapping or to get at the data. String[] typeTokens = contentType.split("/"); String ext = typeTokens[1]; - + if (title != null && !title.isBlank()) { // We've got a title, so use it to build file name StringTokenizer toker = new StringTokenizer(title); @@ -595,15 +572,15 @@ private String createFileName(Weblog weblog, String title, String contentType) { fileName = tmp + "." + ext; } else { fileName = tmp; - } - } else { + } + } else { // No title or text, so instead we'll use the item's date // in YYYYMMDD format to form the file name SimpleDateFormat sdf = new SimpleDateFormat(); sdf.applyPattern("yyyyMMddHHSS"); fileName = weblog.getHandle()+"-"+sdf.format(new Date())+"."+ext; } - + return fileName; } } 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 55e1c576ef..ff8537f6bf 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 @@ -16,43 +16,35 @@ * directory of this distribution. */ package org.apache.roller.weblogger.webservices.atomprotocol; -import com.rometools.propono.atom.common.Categories; -import com.rometools.propono.atom.server.AtomRequest; + import java.util.StringTokenizer; import javax.servlet.http.HttpServletRequest; -import org.apache.commons.codec.binary.Base64; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.apache.roller.util.RollerConstants; -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.WeblogEntry; -import org.apache.roller.weblogger.pojos.Weblog; -import org.apache.roller.weblogger.util.WSSEUtilities; -import com.rometools.propono.atom.common.AtomService; -import com.rometools.propono.atom.server.AtomException; -import com.rometools.propono.atom.server.AtomHandler; -import com.rometools.propono.atom.server.AtomMediaResource; -import com.rometools.propono.atom.server.AtomNotFoundException; -import com.rometools.rome.feed.atom.Entry; -import com.rometools.rome.feed.atom.Feed; -import java.nio.charset.StandardCharsets; import javax.servlet.http.HttpServletResponse; import net.oauth.OAuthAccessor; import net.oauth.OAuthMessage; import net.oauth.server.OAuthServlet; +import org.apache.commons.codec.binary.Base64; 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.OAuthManager; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.business.WebloggerFactory; import org.apache.roller.weblogger.config.WebloggerConfig; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +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.WeblogPermission; import org.apache.roller.weblogger.ui.core.RollerContext; /** - * Weblogger's ROME Propono-based Atom Protocol implementation. + * Weblogger's Atom Publishing Protocol implementation. This implementation uses + * only the JDK XML (StAX) APIs for serialization and parsing — it does not + * depend on ROME or Propono. * * Each Weblogger workspace has two collections, one that accepts entries and * that accepts everything. The entries collection represents the weblog @@ -91,7 +83,7 @@ * * @author David M Johnson */ -public class RollerAtomHandler implements AtomHandler { +public class RollerAtomHandler { protected Weblogger roller = null; protected User user = null; protected int maxEntries = 20; @@ -120,10 +112,6 @@ public RollerAtomHandler(HttpServletRequest request, HttpServletResponse respons String userName; if ("oauth".equals(WebloggerRuntimeConfig.getProperty("webservices.atomPubAuth"))) { userName = authenticationOAUTH(request, response); - - } else if ("wsse".equals(WebloggerRuntimeConfig.getProperty("webservices.atomPubAuth"))) { - userName = authenticateWSSE(request); - } else { // default to basic userName = authenticateBASIC(request); @@ -143,7 +131,6 @@ public RollerAtomHandler(HttpServletRequest request, HttpServletResponse respons /** * Return weblogHandle of authenticated user or null if there is none. */ - @Override public String getAuthenticatedUsername() { String ret = null; if (this.user != null) { @@ -158,10 +145,9 @@ public String getAuthenticatedUsername() { * Return Atom service document for site, getting blog-name from pathInfo. * The workspace will contain collections for entries, categories and resources. */ - @Override - public AtomService getAtomService(AtomRequest areq) throws AtomException { + public AtomServiceDoc getAtomService(AtomRequest areq) throws AtomException { try { - return new RollerAtomService(user, atomURL); + return new RollerAtomService(user, atomURL).getServiceDoc(); } catch (WebloggerException ex) { log.error("Unable to create Service Document", ex); throw new AtomException("ERROR creating Service Document", ex); @@ -173,8 +159,7 @@ public AtomService getAtomService(AtomRequest areq) throws AtomException { /** * Create entry in the entry collection (a Weblogger blog has only one). */ - @Override - public Entry postEntry(AtomRequest areq, Entry entry) throws AtomException { + public AtomEntry postEntry(AtomRequest areq, AtomEntry entry) throws AtomException { EntryCollection ecol = new EntryCollection(user, atomURL); return ecol.postEntry(areq, entry); } @@ -182,12 +167,8 @@ public Entry postEntry(AtomRequest areq, Entry entry) throws AtomException { /** * Create new resource in generic collection (a Weblogger blog has only one). - * TODO: can we avoid saving temporary file? - * TODO: do we need to handle mutli-part MIME uploads? - * TODO: use Jakarta Commons File-upload? */ - @Override - public Entry postMedia(AtomRequest areq, Entry entry) + public AtomEntry postMedia(AtomRequest areq, AtomEntry entry) throws AtomException { MediaCollection mcol = new MediaCollection(user, atomURL); return mcol.postMedia(areq, entry); @@ -206,8 +187,7 @@ public Entry postMedia(AtomRequest areq, Entry entry) * //resources/offset * */ - @Override - public Feed getCollection(AtomRequest areq) throws AtomException { + public AtomFeed getCollection(AtomRequest areq) throws AtomException { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); if (pathInfo.length > 0 && pathInfo[1].equals("entries")) { @@ -222,17 +202,10 @@ public Feed getCollection(AtomRequest areq) throws AtomException { } - @Override - public Categories getCategories(AtomRequest arg0) throws AtomException { - throw new UnsupportedOperationException("Not supported yet."); - } - - /** * Retrieve entry, URI like this /blog-name/entry/id */ - @Override - public Entry getEntry(AtomRequest areq) throws AtomException { + public AtomEntry getEntry(AtomRequest areq) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); // URI is /blogname/entries/entryid @@ -251,7 +224,6 @@ public Entry getEntry(AtomRequest areq) throws AtomException { /** * Expects pathInfo of form /blog-name/resource/path/name */ - @Override public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException { MediaCollection mcol = new MediaCollection(user, atomURL); return mcol.getMediaResource(areq); @@ -263,8 +235,7 @@ public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException /** * Update entry, URI like this /blog-name/entry/id */ - @Override - public void putEntry(AtomRequest areq, Entry entry) throws AtomException { + public void putEntry(AtomRequest areq, AtomEntry entry) throws AtomException { EntryCollection ecol = new EntryCollection(user, atomURL); ecol.putEntry(areq, entry); } @@ -274,7 +245,6 @@ public void putEntry(AtomRequest areq, Entry entry) throws AtomException { * Update resource specified by pathInfo using data from input stream. * Expects pathInfo of form /blog-name/resource/path/name */ - @Override public void putMedia(AtomRequest areq) throws AtomException { MediaCollection mcol = new MediaCollection(user, atomURL); mcol.putMedia(areq); @@ -286,7 +256,6 @@ public void putMedia(AtomRequest areq) throws AtomException { /** * Delete entry, URI like this /blog-name/entry/id */ - @Override public void deleteEntry(AtomRequest areq) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); @@ -311,7 +280,6 @@ public void deleteEntry(AtomRequest areq) throws AtomException { /** * True if URL is the introspection URI. */ - @Override public boolean isAtomServiceURI(AtomRequest areq) { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); return pathInfo.length == 0; @@ -320,7 +288,6 @@ public boolean isAtomServiceURI(AtomRequest areq) { /** * True if URL is a entry URI. */ - @Override public boolean isEntryURI(AtomRequest areq) { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); if (pathInfo.length > 2 && pathInfo[1].equals("entry")) { @@ -335,7 +302,6 @@ public boolean isEntryURI(AtomRequest areq) { /** * True if URL is media edit URI. Media can be updated, but not metadata. */ - @Override public boolean isMediaEditURI(AtomRequest areq) { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); if (pathInfo.length > 1 && pathInfo[1].equals("resource")) { @@ -347,7 +313,6 @@ public boolean isMediaEditURI(AtomRequest areq) { /** * True if URL is a collection URI of any sort. */ - @Override public boolean isCollectionURI(AtomRequest areq) { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); if (pathInfo.length > 1 && pathInfo[1].equals("entries")) { @@ -362,11 +327,6 @@ public boolean isCollectionURI(AtomRequest areq) { return false; } - @Override - public boolean isCategoriesURI(AtomRequest arg0) { - return false; - } - //------------------------------------------------------------------ permissions @@ -410,53 +370,6 @@ public static boolean canView(User u, Weblog website) { //-------------------------------------------------------------- authentication - /** - * Perform WSSE authentication based on information in request. - * Will not work if Weblogger password encryption is turned on. - */ - protected String authenticateWSSE(HttpServletRequest request) { - String wsseHeader = request.getHeader("X-WSSE"); - String ret = null; - if (wsseHeader == null) { - return ret; - } - String userName = null; - String created = null; - String nonce = null; - String passwordDigest = null; - String[] tokens = wsseHeader.split(","); - for (int i = 0; i < tokens.length; i++) { - int index = tokens[i].indexOf('='); - if (index != -1) { - String key = tokens[i].substring(0, index).trim(); - String value = tokens[i].substring(index + 1).trim(); - value = value.replace("\"", ""); - if (key.startsWith("UsernameToken")) { - userName = value; - } else if (key.equalsIgnoreCase("nonce")) { - nonce = value; - } else if (key.equalsIgnoreCase("passworddigest")) { - passwordDigest = value; - } else if (key.equalsIgnoreCase("created")) { - created = value; - } - } - } - String digest = null; - try { - User inUser = roller.getUserManager().getUserByUserName(userName); - digest = WSSEUtilities.generateDigest(WSSEUtilities.base64Decode(nonce), - created.getBytes(StandardCharsets.UTF_8), - inUser.getPassword().getBytes(StandardCharsets.UTF_8)); - if (digest.equals(passwordDigest)) { - ret = userName; - } - } catch (Exception e) { - log.error("During wsseAuthenticataion: " + e.getMessage(), e); - } - return ret; - } - /** * BASIC authentication. */ @@ -478,7 +391,7 @@ public String authenticateBASIC(HttpServletRequest request) { User inUser = roller.getUserManager().getUserByUserName(userID); if (inUser.getEnabled()) { String password = userPass.substring(p+1); - valid = RollerContext.getPasswordEncoder().matches(password, user.getPassword()); + valid = RollerContext.getPasswordEncoder().matches(password, inUser.getPassword()); } } } 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 60342c63c1..74094efbab 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 @@ -36,30 +36,24 @@ import org.apache.roller.weblogger.pojos.WeblogPermission; import org.apache.roller.weblogger.util.Utilities; -import com.rometools.propono.atom.common.AtomService; -import com.rometools.propono.atom.common.Categories; -import com.rometools.propono.atom.common.Collection; -import com.rometools.propono.atom.common.Workspace; -import com.rometools.propono.atom.server.AtomException; -import com.rometools.rome.feed.atom.Category; - /** - * Roller's Atom service. + * Builds Roller's APP service document. The document is assembled into an + * {@link AtomServiceDoc} wire-model object during construction and is available + * via {@link #getServiceDoc()}. */ -public class RollerAtomService extends AtomService { +public class RollerAtomService { + + private final AtomServiceDoc serviceDoc = new AtomServiceDoc(); - /** - * Creates a new instance of FileBasedAtomService. - */ public RollerAtomService(User user, String atomURL) throws WebloggerException, AtomException { Weblogger roller = WebloggerFactory.getWeblogger(); List perms; - + if (!WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub")) { throw new AtomException("AtomPub not enabled for this Roller installation"); } - + try { perms = roller.getUserManager().getWeblogPermissions(user); } catch (WebloggerException re) { @@ -74,38 +68,41 @@ public RollerAtomService(User user, String atomURL) throws WebloggerException, A if (perms != null) { for (WeblogPermission perm : perms) { Weblog weblog = perm.getWeblog(); - Workspace workspace; + AtomWorkspace workspace; try { // Create workspace to represent weblog - workspace = new Workspace(Utilities.removeHTML(perm.getWeblog().getName()), "text"); - addWorkspace(workspace); + workspace = new AtomWorkspace(); + workspace.setTitle(Utilities.removeHTML(perm.getWeblog().getName())); + serviceDoc.getWorkspaces().add(workspace); // Create collection for entries within that workspace - Collection entryCol = new Collection("Weblog Entries", "text", atomURL + "/" + weblog.getHandle() + "/entries"); - entryCol.addAccept("application/atom+xml;type=entry"); + AtomCollection entryCol = new AtomCollection(); + entryCol.setTitle("Weblog Entries"); + 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(); - Categories cats = new Categories(); + AtomCategories cats = new AtomCategories(); cats.setFixed(true); cats.setScheme(getWeblogCategoryScheme(weblog)); List rollerCats = roller.getWeblogEntryManager().getWeblogCategories(weblog); for (WeblogCategory rollerCat : rollerCats) { - Category cat = new Category(); + AtomCategory cat = new AtomCategory(); cat.setTerm(rollerCat.getName()); cat.setLabel(rollerCat.getName()); - cats.addCategory(cat); + cats.getCategories().add(cat); } - entryCol.addCategories(cats); + entryCol.getCategories().add(cats); // Indicte that free form categories are allowed - Categories tags = new Categories(); + AtomCategories tags = new AtomCategories(); tags.setFixed(false); - entryCol.addCategories(tags); + entryCol.getCategories().add(tags); - workspace.addCollection(entryCol); + workspace.getCollections().add(entryCol); } catch (Exception e) { throw new AtomException("Creating weblog entry collection for service doc", e); } @@ -115,11 +112,12 @@ public RollerAtomService(User user, String atomURL) throws WebloggerException, A MediaFileManager mgr = roller.getMediaFileManager(); List dirs = mgr.getMediaFileDirectories(weblog); for (MediaFileDirectory dir : dirs) { - Collection uploadSubCol = new Collection( - "Media Files: " + dir.getName(), "text", + AtomCollection uploadSubCol = new AtomCollection(); + uploadSubCol.setTitle("Media Files: " + dir.getName()); + uploadSubCol.setHref( atomURL + "/" + weblog.getHandle() + "/resources/" + dir.getName()); uploadSubCol.setAccepts(uploadAccepts); - workspace.addCollection(uploadSubCol); + workspace.getCollections().add(uploadSubCol); } } catch (Exception e) { @@ -128,15 +126,22 @@ public RollerAtomService(User user, String atomURL) throws WebloggerException, A } } } - + + /** + * The assembled service document. + */ + public AtomServiceDoc getServiceDoc() { + return serviceDoc; + } + /** - * Build accept range by taking things that appear to be content-type rules + * Build accept range by taking things that appear to be content-type rules * from site's file-upload allowed extensions. */ private List getAcceptedContentTypeRange() throws WebloggerException { List accepts = new ArrayList<>(); Weblogger roller = WebloggerFactory.getWeblogger(); - Map config = roller.getPropertiesManager().getProperties(); + Map config = roller.getPropertiesManager().getProperties(); String allows = config.get("uploads.types.allowed").getValue(); String[] rules = StringUtils.split(StringUtils.deleteWhitespace(allows), ","); if (rules != null) { @@ -147,11 +152,10 @@ private List getAcceptedContentTypeRange() throws WebloggerException { accepts.add(rule); } } - return accepts; - } - + return accepts; + } + public static String getWeblogCategoryScheme(Weblog website) { return WebloggerFactory.getWeblogger().getUrlStrategy().getWeblogURL(website, null, true); } } - 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 new file mode 100644 index 0000000000..062e538563 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java @@ -0,0 +1,216 @@ +/* +* 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.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Dispatcher servlet for Roller's Atom Publishing Protocol (RFC 5023) + * implementation. Replaces the ROME Propono {@code AtomServlet}: it + * authenticates the request, routes by HTTP method and URI shape to + * {@link RollerAtomHandler}, and serializes/parses Atom XML via {@link AtomWriter} + * and {@link AtomReader}. No ROME or Propono types are involved. + */ +public class RollerAtomServlet extends HttpServlet { + + private static final Log log = + LogFactory.getFactory().getInstance(RollerAtomServlet.class); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws IOException { + process(request, response, "GET"); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws IOException { + process(request, response, "POST"); + } + + @Override + protected void doPut(HttpServletRequest request, HttpServletResponse response) + throws IOException { + process(request, response, "PUT"); + } + + @Override + protected void doDelete(HttpServletRequest request, HttpServletResponse response) + throws IOException { + process(request, response, "DELETE"); + } + + private void process(HttpServletRequest request, HttpServletResponse response, String method) + throws IOException { + + RollerAtomHandler handler = new RollerAtomHandler(request, response); + String userName = handler.getAuthenticatedUsername(); + if (userName == null) { + // The OAuth path may have already written a challenge/error response. + if (!response.isCommitted()) { + response.setHeader("WWW-Authenticate", "Basic realm=\"Roller\""); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication error"); + } + return; + } + + byte[] body = null; + if ("POST".equals(method) || "PUT".equals(method)) { + body = readBody(request); + } + AtomRequest areq = new AtomRequest(request, body); + + try { + switch (method) { + case "GET": + doGet(handler, areq, response); + break; + case "POST": + doPost(handler, areq, response); + break; + case "PUT": + doPut(handler, areq, response); + break; + case "DELETE": + handler.deleteEntry(areq); + response.setStatus(HttpServletResponse.SC_OK); + break; + default: + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } + } catch (AtomException ae) { + log.debug("Returning error to client: " + ae.getMessage(), ae); + if (!response.isCommitted()) { + response.sendError(ae.getStatus(), ae.getMessage()); + } + } catch (Exception e) { + log.error("Unexpected error handling AtomPub request", e); + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); + } + } + } + + private void doGet(RollerAtomHandler handler, AtomRequest areq, HttpServletResponse response) + throws AtomException, IOException { + + if (handler.isAtomServiceURI(areq)) { + AtomServiceDoc service = handler.getAtomService(areq); + response.setContentType(AtomConstants.SERVICE_MEDIA_TYPE); + new AtomWriter().writeServiceDoc(response.getOutputStream(), service); + + } else if (handler.isCollectionURI(areq)) { + AtomFeed feed = handler.getCollection(areq); + response.setContentType(AtomConstants.FEED_MEDIA_TYPE); + new AtomWriter().writeFeed(response.getOutputStream(), feed); + + } else if (handler.isEntryURI(areq)) { + AtomEntry entry = handler.getEntry(areq); + response.setContentType(AtomConstants.ENTRY_MEDIA_TYPE); + new AtomWriter().writeEntry(response.getOutputStream(), entry); + + } else if (handler.isMediaEditURI(areq)) { + AtomMediaResource resource = handler.getMediaResource(areq); + if (resource.getContentType() != null) { + response.setContentType(resource.getContentType()); + } + response.setContentLengthLong(resource.getContentLength()); + if (resource.getLastModified() != null) { + response.setDateHeader("Last-Modified", resource.getLastModified().getTime()); + } + try (InputStream in = resource.getInputStream()) { + in.transferTo(response.getOutputStream()); + } + + } else { + throw new AtomNotFoundException("Cannot find specified resource"); + } + } + + private void doPost(RollerAtomHandler handler, AtomRequest areq, HttpServletResponse response) + throws AtomException { + + if (!handler.isCollectionURI(areq)) { + throw new AtomNotFoundException("Cannot POST to specified URI"); + } + + String contentType = areq.getContentType(); + AtomEntry created; + if (contentType != null && contentType.startsWith("application/atom+xml")) { + AtomEntry entry = new AtomReader().parseEntry(areq.getInputStream()); + created = handler.postEntry(areq, entry); + } else { + // Media POST: synthesize an entry carrying the request content type + // and Slug; the binary data is read from the request body. + AtomEntry mediaEntry = new AtomEntry(); + AtomContent content = new AtomContent(); + content.setType(contentType); + mediaEntry.setContent(content); + mediaEntry.setTitle(areq.getHeader("Slug")); + created = handler.postMedia(areq, mediaEntry); + } + writeCreated(response, created); + } + + private void doPut(RollerAtomHandler handler, AtomRequest areq, HttpServletResponse response) + throws AtomException { + + if (handler.isEntryURI(areq)) { + AtomEntry entry = new AtomReader().parseEntry(areq.getInputStream()); + handler.putEntry(areq, entry); + response.setStatus(HttpServletResponse.SC_OK); + } else if (handler.isMediaEditURI(areq)) { + handler.putMedia(areq); + response.setStatus(HttpServletResponse.SC_OK); + } else { + throw new AtomNotFoundException("Cannot PUT to specified URI"); + } + } + + private void writeCreated(HttpServletResponse response, AtomEntry entry) + throws AtomException { + String editHref = entry.getLinkHref("edit"); + if (editHref != null) { + response.setHeader("Location", editHref); + response.setHeader("Content-Location", editHref); + } + response.setStatus(HttpServletResponse.SC_CREATED); + response.setContentType(AtomConstants.ENTRY_MEDIA_TYPE); + try { + OutputStream out = response.getOutputStream(); + new AtomWriter().writeEntry(out, entry); + } catch (IOException ioe) { + throw new AtomException("Error writing created entry", ioe); + } + } + + private byte[] readBody(HttpServletRequest request) throws IOException { + try (InputStream in = request.getInputStream()) { + return in.readAllBytes(); + } + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java index 412ae3a322..b76e13aace 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java @@ -17,7 +17,8 @@ */ /** -

Atom Publising Protocol (AtomPub) implementation using ROME Propono.

+

Atom Publishing Protocol (AtomPub) implementation built on the JDK StAX XML + APIs. It does not depend on ROME or Propono.

End-point is at [context]/roller-services/app

*/ package org.apache.roller.weblogger.webservices.atomprotocol; \ No newline at end of file diff --git a/app/src/main/resources/ApplicationResources.properties b/app/src/main/resources/ApplicationResources.properties index 66072c23f0..e8438ae0d7 100644 --- a/app/src/main/resources/ApplicationResources.properties +++ b/app/src/main/resources/ApplicationResources.properties @@ -338,7 +338,7 @@ configForm.editorPages=Editor Pages configForm.webServicesSettings=Web Services Settings configForm.enableAtomPub=Enable Atom Publishing Protocol -configForm.AtomPubAuth=AtomPub authentication (basic, oauth, or wsse) +configForm.AtomPubAuth=AtomPub authentication (basic or oauth) configForm.enableXmlRpc=Enable Blogger / MetaWeblog API configForm.weblogSettings=Weblog Rendering Settings diff --git a/app/src/main/resources/ApplicationResources_ja.properties b/app/src/main/resources/ApplicationResources_ja.properties index 8802be030d..34af433211 100644 --- a/app/src/main/resources/ApplicationResources_ja.properties +++ b/app/src/main/resources/ApplicationResources_ja.properties @@ -1383,7 +1383,7 @@ pingTargetAdd.subtitle=Ping\u30BF\u30FC\u30B2\u30C3\u30C8\u306E\u8FFD\u52A0 planetSubscription.feedUrl=\u30CB\u30E5\u30FC\u30B9\u30D5\u30A3\u30FC\u30C9URL mediaFileSuccess.noEnclosure=\u30A8\u30F3\u30AF\u30ED\u30FC\u30B8\u30E3\u306A\u3057 userRegister.tip.ready=\u3088\u308D\u3057\u3051\u308C\u3070\u3001\u4EE5\u4E0B\u306E\u30DC\u30BF\u30F3\u3092\u30AF\u30EA\u30C3\u30AF\u3057\u3066\u304F\u3060\u3055\u3044\u3002 -configForm.AtomPubAuth=AtomPub\u8A8D\u8A3C (basic, oauth, \u307E\u305F\u306F wsse) +configForm.AtomPubAuth=AtomPub\u8A8D\u8A3C (basic \u307E\u305F\u306F oauth) ConfigForm.proxyPort=Feed fetcher\u304C\u4F7F\u7528\u3059\u308B\u30D7\u30ED\u30AD\u30B7\u306E\u30DD\u30FC\u30C8 mediaFileView.searchTitle=\u691C\u7D22\u7D50\u679C weblogEdit.enclosureLength=\u9577\u3055 diff --git a/app/src/main/resources/ApplicationResources_zh_CN.properties b/app/src/main/resources/ApplicationResources_zh_CN.properties index 93ac844968..053a850ae9 100644 --- a/app/src/main/resources/ApplicationResources_zh_CN.properties +++ b/app/src/main/resources/ApplicationResources_zh_CN.properties @@ -337,7 +337,7 @@ configForm.editorPages=\u7F16\u8F91\u5668\u9875\u9762 configForm.webServicesSettings=Web\u670D\u52A1\u8BBE\u7F6E configForm.enableAtomPub=\u542F\u7528 Atom \u53D1\u5E03\u534F\u8BAE -configForm.AtomPubAuth=AtomPub \u8BA4\u8BC1\u65B9\u5F0F (basic / oauth / wsse) +configForm.AtomPubAuth=AtomPub \u8BA4\u8BC1\u65B9\u5F0F (basic / oauth) configForm.enableXmlRpc=\u542F\u7528 Blogger / MetaWeblog API configForm.weblogSettings=\u535A\u5BA2\u663E\u793A\u8BBE\u7F6E diff --git a/app/src/main/resources/propono.properties b/app/src/main/resources/propono.properties deleted file mode 100644 index bcd565e64b..0000000000 --- a/app/src/main/resources/propono.properties +++ /dev/null @@ -1,2 +0,0 @@ -com.rometools.propono.atom.server.AtomHandlerFactory=\ -org.apache.roller.weblogger.webservices.atomprotocol.RollerAtomHandlerFactory diff --git a/app/src/main/webapp/WEB-INF/web.xml b/app/src/main/webapp/WEB-INF/web.xml index 0418832da1..14e4bc37a9 100644 --- a/app/src/main/webapp/WEB-INF/web.xml +++ b/app/src/main/webapp/WEB-INF/web.xml @@ -267,7 +267,7 @@ AtomServlet - com.rometools.propono.atom.server.AtomServlet + org.apache.roller.weblogger.webservices.atomprotocol.RollerAtomServlet diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntryTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntryTest.java new file mode 100644 index 0000000000..8956a3ec17 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntryTest.java @@ -0,0 +1,55 @@ +/* +* 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link AtomEntry}, in particular the link lookup helper the + * dispatcher servlet relies on to find the edit URI. + */ +public class AtomEntryTest { + + @Test + public void testGetLinkHrefFindsMatchingRel() { + AtomEntry entry = new AtomEntry(); + entry.getLinks().add(new AtomLink("alternate", "http://example.com/blog/1")); + entry.getLinks().add(new AtomLink("edit", "http://example.com/app/blog/entry/1")); + + assertEquals("http://example.com/blog/1", entry.getLinkHref("alternate")); + assertEquals("http://example.com/app/blog/entry/1", entry.getLinkHref("edit")); + } + + @Test + public void testGetLinkHrefReturnsNullWhenMissing() { + AtomEntry entry = new AtomEntry(); + entry.getLinks().add(new AtomLink("alternate", "http://example.com/blog/1")); + assertNull(entry.getLinkHref("edit")); + } + + @Test + public void testGetLinkHrefReturnsFirstMatch() { + AtomEntry entry = new AtomEntry(); + entry.getLinks().add(new AtomLink("edit", "first")); + entry.getLinks().add(new AtomLink("edit", "second")); + assertEquals("first", entry.getLinkHref("edit")); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomExceptionTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomExceptionTest.java new file mode 100644 index 0000000000..8219cb3a60 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomExceptionTest.java @@ -0,0 +1,59 @@ +/* +* 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import javax.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the AtomPub exception hierarchy and the HTTP status codes the + * dispatcher servlet maps from them. + */ +public class AtomExceptionTest { + + @Test + public void testBaseExceptionDefaultsToServerError() { + AtomException ex = new AtomException("boom"); + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getStatus()); + assertEquals("boom", ex.getMessage()); + } + + @Test + public void testBaseExceptionPreservesCause() { + Throwable cause = new IllegalStateException("root"); + AtomException ex = new AtomException("boom", cause); + assertSame(cause, ex.getCause()); + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getStatus()); + } + + @Test + public void testNotFoundIs404() { + assertEquals(HttpServletResponse.SC_NOT_FOUND, + new AtomNotFoundException("missing").getStatus()); + } + + @Test + public void testNotAuthorizedIs401() { + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, + new AtomNotAuthorizedException("nope").getStatus()); + } +} 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 new file mode 100644 index 0000000000..45c2138c29 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java @@ -0,0 +1,136 @@ +/* +* 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 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 java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the StAX-based {@link AtomReader}. + */ +public class AtomReaderTest { + + private AtomEntry parse(String xml) throws AtomException { + return new AtomReader().parseEntry( + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void testParseFullEntry() throws Exception { + String xml = + "" + + "" + + " urn:entry:1" + + " Hello World" + + " 2026-06-03T12:34:56Z" + + " 2026-06-04T01:02:03Z" + + " A summary" + + " <b>Body</b> & more" + + " " + + " " + + " yes" + + ""; + + AtomEntry entry = parse(xml); + + assertEquals("urn:entry:1", entry.getId()); + assertEquals("Hello World", entry.getTitle()); + assertNotNull(entry.getContent()); + assertEquals("html", entry.getContent().getType()); + assertEquals("Body & more", entry.getContent().getValue()); + assertNotNull(entry.getSummary()); + assertEquals("A summary", entry.getSummary().getValue()); + assertEquals(Date.from(Instant.parse("2026-06-03T12:34:56Z")), entry.getPublished()); + assertEquals(Date.from(Instant.parse("2026-06-04T01:02:03Z")), entry.getUpdated()); + assertTrue(entry.isDraft()); + + assertEquals(2, entry.getCategories().size()); + AtomCategory cat = entry.getCategories().get(0); + assertEquals("tech", cat.getTerm()); + assertEquals("http://example.com/cats", cat.getScheme()); + AtomCategory tag = entry.getCategories().get(1); + assertEquals("java", tag.getTerm()); + assertNull(tag.getScheme()); + } + + @Test + public void testDraftDefaultsToFalseWhenNoControl() throws Exception { + String xml = + "" + + "No control"; + assertFalse(parse(xml).isDraft()); + } + + @Test + public void testDraftNoIsNotDraft() throws Exception { + String xml = + "" + + "no"; + assertFalse(parse(xml).isDraft()); + } + + @Test + public void testContentWithSrcHasNoValue() throws Exception { + String xml = + "" + + ""; + AtomEntry entry = parse(xml); + assertEquals("image/png", entry.getContent().getType()); + assertEquals("http://example.com/a.png", entry.getContent().getSrc()); + assertNull(entry.getContent().getValue()); + } + + @Test + public void testParseDateAcceptsZuluAndOffset() { + assertEquals(Date.from(Instant.parse("2026-06-03T12:34:56Z")), + AtomReader.parseDate("2026-06-03T12:34:56Z")); + // 14:34:56+02:00 is the same instant as 12:34:56Z + assertEquals(Date.from(Instant.parse("2026-06-03T12:34:56Z")), + AtomReader.parseDate("2026-06-03T14:34:56+02:00")); + } + + @Test + public void testParseDateReturnsNullForGarbage() { + assertNull(AtomReader.parseDate(null)); + assertNull(AtomReader.parseDate(" ")); + assertNull(AtomReader.parseDate("not-a-date")); + } + + @Test + public void testExternalEntityIsRejected() { + // A DOCTYPE with an external entity must not be processed (XXE guard). + String xml = + "" + + " ]>" + + "&xxe;"; + assertThrows(AtomException.class, () -> parse(xml)); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequestTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequestTest.java new file mode 100644 index 0000000000..75181fba67 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequestTest.java @@ -0,0 +1,97 @@ +/* +* 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Unit tests for {@link AtomRequest}, the lightweight request wrapper that + * buffers the body and normalizes a null pathInfo to "". + */ +public class AtomRequestTest { + + @Mock + private HttpServletRequest request; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testNullPathInfoBecomesEmptyString() { + when(request.getPathInfo()).thenReturn(null); + assertEquals("", new AtomRequest(request, null).getPathInfo()); + } + + @Test + public void testPathInfoPassesThrough() { + when(request.getPathInfo()).thenReturn("/blog/entries"); + assertEquals("/blog/entries", new AtomRequest(request, null).getPathInfo()); + } + + @Test + public void testHeaderAndContentTypeDelegate() { + when(request.getHeader("Slug")).thenReturn("my-slug"); + when(request.getContentType()).thenReturn("application/atom+xml"); + AtomRequest areq = new AtomRequest(request, null); + assertEquals("my-slug", areq.getHeader("Slug")); + assertEquals("application/atom+xml", areq.getContentType()); + } + + @Test + public void testInputStreamReturnsBufferedBody() throws Exception { + byte[] body = "hello body".getBytes(StandardCharsets.UTF_8); + AtomRequest areq = new AtomRequest(request, body); + try (InputStream in = areq.getInputStream()) { + assertArrayEquals(body, in.readAllBytes()); + } + } + + @Test + public void testInputStreamIsFreshEachCall() throws Exception { + byte[] body = "abc".getBytes(StandardCharsets.UTF_8); + AtomRequest areq = new AtomRequest(request, body); + try (InputStream first = areq.getInputStream(); + InputStream second = areq.getInputStream()) { + assertArrayEquals(body, first.readAllBytes()); + // second stream is independent and still readable from the start + assertArrayEquals(body, second.readAllBytes()); + } + } + + @Test + public void testNullBodyYieldsEmptyStream() throws Exception { + AtomRequest areq = new AtomRequest(request, null); + try (InputStream in = areq.getInputStream()) { + assertArrayEquals(new byte[0], in.readAllBytes()); + } + } +} 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 new file mode 100644 index 0000000000..f5d6fdc265 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java @@ -0,0 +1,198 @@ +/* +* 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 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 + * schemas from RFC 4287 (Atom) and RFC 5023 (AtomPub), using Jing. This checks + * that Roller's AtomPub wire format actually conforms to the specifications, + * not just that it is well-formed. + */ +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; + } + + private byte[] writeEntry(AtomEntry entry) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, entry); + return out.toByteArray(); + } + + private AtomEntry textEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId("urn:entry:1"); + entry.setTitle("Hello World"); + entry.setPublished(PUBLISHED); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + entry.setDraft(false); + + AtomContent content = new AtomContent(); + content.setType("html"); + content.setValue("Body & more"); + entry.setContent(content); + + AtomContent summary = new AtomContent(); + summary.setType("html"); + summary.setValue("A summary"); + entry.setSummary(summary); + + AtomPerson author = new AtomPerson(); + author.setName("alice"); + author.setEmail("alice@example.com"); + entry.getAuthors().add(author); + + AtomCategory cat = new AtomCategory(); + cat.setTerm("tech"); + cat.setScheme("http://example.com/cats"); + entry.getCategories().add(cat); + AtomCategory tag = new AtomCategory(); + tag.setTerm("java"); + entry.getCategories().add(tag); + + entry.getLinks().add(new AtomLink("alternate", "http://example.com/blog/1")); + entry.getLinks().add(new AtomLink("edit", "http://example.com/app/blog/entry/1")); + return entry; + } + + private AtomEntry mediaEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId("http://example.com/app/blog/resource/snapshot.png"); + entry.setTitle("snapshot.png"); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + entry.setDraft(false); + + AtomContent content = new AtomContent(); + content.setType("image/png"); + content.setSrc("http://example.com/blog/mediaresource/snapshot.png"); + entry.setContent(content); + + entry.getLinks().add(new AtomLink("alternate", "http://example.com/blog/snapshot.png")); + entry.getLinks().add(new AtomLink("edit", "http://example.com/app/blog/resource/snapshot.png.media-link")); + entry.getLinks().add(new AtomLink("edit-media", "http://example.com/app/blog/resource/snapshot.png")); + return entry; + } + + @Test + public void testTextEntryConformsToAtomSchema() throws Exception { + List errors = validate("/atompub/atom.rnc", writeEntry(textEntry())); + assertTrue(errors.isEmpty(), "Atom entry should be schema-valid but: " + errors); + } + + @Test + public void testMediaEntryConformsToAtomSchema() throws Exception { + List errors = validate("/atompub/atom.rnc", writeEntry(mediaEntry())); + assertTrue(errors.isEmpty(), "media link entry should be schema-valid but: " + errors); + } + + @Test + public void testFeedConformsToAtomSchema() throws Exception { + AtomFeed feed = new AtomFeed(); + feed.setId("urn:feed:1"); + feed.setTitle("My Blog"); + feed.setUpdated(UPDATED); + feed.getLinks().add(new AtomLink("alternate", "http://example.com/blog")); + feed.getEntries().add(textEntry()); + feed.getEntries().add(textEntry()); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeFeed(out, feed); + + List errors = validate("/atompub/atom.rnc", out.toByteArray()); + assertTrue(errors.isEmpty(), "Atom feed should be schema-valid but: " + errors); + } + + @Test + public void testServiceDocConformsToAppSchema() 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 fixed = new AtomCategories(); + fixed.setFixed(true); + fixed.setScheme("http://example.com/cats"); + AtomCategory cat = new AtomCategory(); + cat.setTerm("tech"); + cat.setLabel("tech"); + fixed.getCategories().add(cat); + entries.getCategories().add(fixed); + entries.getCategories().add(new AtomCategories()); // free-form + workspace.getCollections().add(entries); + + AtomCollection media = new AtomCollection(); + media.setTitle("Media Files: default"); + media.setHref("http://example.com/app/blog/resources/default"); + media.setAccepts(Arrays.asList("image/png", "image/jpeg")); + workspace.getCollections().add(media); + + 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 new file mode 100644 index 0000000000..25caf2d149 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java @@ -0,0 +1,255 @@ +/* +* 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 static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; +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.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** + * Unit tests for the StAX-based {@link AtomWriter}. The emitted XML is parsed + * back with a namespace-aware DOM parser so the structure and namespaces can be + * asserted directly. + */ +public class AtomWriterTest { + + 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")); + + private Document parse(byte[] xml) throws Exception { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + return dbf.newDocumentBuilder().parse(new ByteArrayInputStream(xml)); + } + + private String text(Document doc, String ns, String local) { + NodeList nl = doc.getElementsByTagNameNS(ns, local); + return nl.getLength() == 0 ? null : nl.item(0).getTextContent(); + } + + private Element element(Document doc, String ns, String local) { + NodeList nl = doc.getElementsByTagNameNS(ns, local); + return nl.getLength() == 0 ? null : (Element) nl.item(0); + } + + private AtomEntry sampleEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId("urn:entry:1"); + entry.setTitle("Hello World"); + entry.setPublished(PUBLISHED); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + entry.setDraft(false); + + AtomContent content = new AtomContent(); + content.setType("html"); + content.setValue("Body & more"); + entry.setContent(content); + + AtomContent summary = new AtomContent(); + summary.setType("html"); + summary.setValue("A summary"); + entry.setSummary(summary); + + AtomPerson author = new AtomPerson(); + author.setName("alice"); + author.setEmail("alice@example.com"); + entry.getAuthors().add(author); + + AtomCategory cat = new AtomCategory(); + cat.setTerm("tech"); + cat.setScheme("http://example.com/cats"); + entry.getCategories().add(cat); + AtomCategory tag = new AtomCategory(); + tag.setTerm("java"); + entry.getCategories().add(tag); + + entry.getLinks().add(new AtomLink("alternate", "http://example.com/blog/1")); + entry.getLinks().add(new AtomLink("edit", "http://example.com/app/blog/entry/1")); + return entry; + } + + @Test + public void testWriteEntryStructure() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, sampleEntry()); + Document doc = parse(out.toByteArray()); + + assertEquals("entry", doc.getDocumentElement().getLocalName()); + assertEquals(ATOM_NS, doc.getDocumentElement().getNamespaceURI()); + + assertEquals("urn:entry:1", text(doc, ATOM_NS, "id")); + assertEquals("Hello World", text(doc, ATOM_NS, "title")); + assertEquals(AtomWriter.formatDate(PUBLISHED), text(doc, ATOM_NS, "published")); + assertEquals(AtomWriter.formatDate(UPDATED), text(doc, ATOM_NS, "updated")); + + Element content = element(doc, ATOM_NS, "content"); + assertEquals("html", content.getAttribute("type")); + // markup is escaped on the wire but DOM gives us back the original text + assertEquals("Body & more", content.getTextContent()); + + assertEquals("A summary", text(doc, ATOM_NS, "summary")); + assertEquals("alice", text(doc, ATOM_NS, "name")); + + // draft "no" plus app:edited present + assertEquals("no", text(doc, APP_NS, "draft")); + assertEquals(AtomWriter.formatDate(UPDATED), text(doc, APP_NS, "edited")); + } + + @Test + public void testWriteEntryDraftYes() throws Exception { + AtomEntry entry = sampleEntry(); + entry.setDraft(true); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, entry); + assertEquals("yes", text(parse(out.toByteArray()), APP_NS, "draft")); + } + + @Test + public void testWriteEntryCategoriesSchemeAndTag() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, sampleEntry()); + Document doc = parse(out.toByteArray()); + + NodeList cats = doc.getElementsByTagNameNS(ATOM_NS, "category"); + assertEquals(2, cats.getLength()); + Element tech = (Element) cats.item(0); + assertEquals("tech", tech.getAttribute("term")); + assertEquals("http://example.com/cats", tech.getAttribute("scheme")); + Element java = (Element) cats.item(1); + assertEquals("java", java.getAttribute("term")); + // a tag carries no scheme attribute at all + assertFalse(java.hasAttribute("scheme")); + } + + @Test + public void testWriteEntryEditLink() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, sampleEntry()); + Document doc = parse(out.toByteArray()); + + NodeList links = doc.getElementsByTagNameNS(ATOM_NS, "link"); + assertEquals(2, links.getLength()); + boolean foundEdit = false; + for (int i = 0; i < links.getLength(); i++) { + Element link = (Element) links.item(i); + if ("edit".equals(link.getAttribute("rel"))) { + assertEquals("http://example.com/app/blog/entry/1", link.getAttribute("href")); + foundEdit = true; + } + } + assertTrue(foundEdit); + } + + @Test + public void testWriteFeed() throws Exception { + AtomFeed feed = new AtomFeed(); + feed.setId("urn:feed:1"); + feed.setTitle("My Blog"); + feed.setUpdated(UPDATED); + feed.getLinks().add(new AtomLink("alternate", "http://example.com/blog")); + feed.getEntries().add(sampleEntry()); + feed.getEntries().add(sampleEntry()); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeFeed(out, feed); + Document doc = parse(out.toByteArray()); + + assertEquals("feed", doc.getDocumentElement().getLocalName()); + assertEquals(ATOM_NS, doc.getDocumentElement().getNamespaceURI()); + assertEquals("My Blog", text(doc, ATOM_NS, "title")); + assertEquals(2, doc.getElementsByTagNameNS(ATOM_NS, "entry").getLength()); + } + + @Test + public void testWriteServiceDoc() throws Exception { + AtomServiceDoc service = new AtomServiceDoc(); + AtomWorkspace workspace = new AtomWorkspace(); + workspace.setTitle("My Weblog"); + service.getWorkspaces().add(workspace); + + AtomCollection collection = new AtomCollection(); + collection.setTitle("Weblog Entries"); + collection.setHref("http://example.com/app/blog/entries"); + collection.setAccepts(Arrays.asList("application/atom+xml;type=entry")); + AtomCategories cats = new AtomCategories(); + cats.setFixed(true); + cats.setScheme("http://example.com/cats"); + AtomCategory cat = new AtomCategory(); + cat.setTerm("tech"); + cat.setLabel("tech"); + cats.getCategories().add(cat); + collection.getCategories().add(cats); + workspace.getCollections().add(collection); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeServiceDoc(out, service); + Document doc = parse(out.toByteArray()); + + assertEquals("service", doc.getDocumentElement().getLocalName()); + assertEquals(APP_NS, doc.getDocumentElement().getNamespaceURI()); + assertNotNull(element(doc, APP_NS, "workspace")); + + Element coll = element(doc, APP_NS, "collection"); + assertEquals("http://example.com/app/blog/entries", coll.getAttribute("href")); + // titles are atom:title (Atom namespace) even inside the service doc + assertEquals("My Weblog", text(doc, ATOM_NS, "title")); + assertEquals("application/atom+xml;type=entry", text(doc, APP_NS, "accept")); + + Element categories = element(doc, APP_NS, "categories"); + assertEquals("yes", categories.getAttribute("fixed")); + assertEquals("http://example.com/cats", categories.getAttribute("scheme")); + assertEquals("tech", element(doc, ATOM_NS, "category").getAttribute("term")); + } + + @Test + public void testEntryRoundTripThroughReader() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, sampleEntry()); + + AtomEntry parsed = new AtomReader().parseEntry(new ByteArrayInputStream(out.toByteArray())); + + assertEquals("urn:entry:1", parsed.getId()); + assertEquals("Hello World", parsed.getTitle()); + assertEquals("Body & more", parsed.getContent().getValue()); + assertEquals("A summary", parsed.getSummary().getValue()); + assertEquals(PUBLISHED, parsed.getPublished()); + assertEquals(UPDATED, parsed.getUpdated()); + assertFalse(parsed.isDraft()); + assertEquals(2, parsed.getCategories().size()); + assertEquals("http://example.com/cats", parsed.getCategories().get(0).getScheme()); + assertNull(parsed.getCategories().get(1).getScheme()); + } +} 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 new file mode 100644 index 0000000000..ae36339fdd --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java @@ -0,0 +1,244 @@ +/* +* 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 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.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.roller.weblogger.TestUtils; +import org.apache.roller.weblogger.business.MediaFileManager; +import org.apache.roller.weblogger.business.PropertiesManager; +import org.apache.roller.weblogger.business.WeblogEntryManager; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.pojos.MediaFile; +import org.apache.roller.weblogger.pojos.MediaFileDirectory; +import org.apache.roller.weblogger.pojos.RuntimeConfigProperty; +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.WeblogEntry.PubStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests that exercise the AtomPub server handlers against a real + * (in-memory Derby) Roller, driving the full create / retrieve / update / delete + * lifecycle through {@link EntryCollection}, {@link MediaCollection} and + * {@link RollerAtomService}. The HTTP transport and BASIC authentication are not + * exercised here (they require the Spring web context); those are covered by an + * over-the-wire exerciser such as APE run against a deployed instance. + */ +public class RollerAtomProtocolTest { + + private static final String HANDLE = "atomtestblog"; + private static final String ATOM_URL = "http://localhost/roller/roller-services/app"; + + private User testUser; + private Weblog testWeblog; + + @BeforeEach + public void setUp() throws Exception { + TestUtils.setupWeblogger(); + testUser = TestUtils.setupUser("atomtestuser"); + testWeblog = TestUtils.setupWeblog(HANDLE, testUser); + + // RollerAtomService requires AtomPub to be enabled; media tests need uploads enabled + PropertiesManager pmgr = WebloggerFactory.getWeblogger().getPropertiesManager(); + RuntimeConfigProperty enableAtomPub = pmgr.getProperty("webservices.enableAtomPub"); + enableAtomPub.setValue("true"); + pmgr.saveProperty(enableAtomPub); + RuntimeConfigProperty enableUploads = pmgr.getProperty("uploads.enabled"); + enableUploads.setValue("true"); + pmgr.saveProperty(enableUploads); + + TestUtils.endSession(true); + } + + @AfterEach + public void tearDown() throws Exception { + TestUtils.teardownWeblog(testWeblog.getId()); + TestUtils.teardownUser(testUser.getUserName()); + TestUtils.endSession(true); + } + + private User managedUser() throws Exception { + return TestUtils.getManagedUser(testUser); + } + + /** Build an AtomRequest backed by a mocked HttpServletRequest. */ + private AtomRequest request(String pathInfo, String contentType, String slug, byte[] body) { + HttpServletRequest req = mock(HttpServletRequest.class); + lenient().when(req.getPathInfo()).thenReturn(pathInfo); + lenient().when(req.getContentType()).thenReturn(contentType); + lenient().when(req.getHeader("Slug")).thenReturn(slug); + return new AtomRequest(req, body); + } + + private AtomEntry sampleEntry(String title, String body, boolean draft) { + AtomEntry entry = new AtomEntry(); + entry.setTitle(title); + AtomContent content = new AtomContent(); + content.setType("html"); + content.setValue(body); + entry.setContent(content); + entry.setDraft(draft); + // a free-form tag (no scheme); the weblog category will default + AtomCategory tag = new AtomCategory(); + tag.setTerm("testing"); + entry.getCategories().add(tag); + return entry; + } + + private String entryIdFromEditLink(AtomEntry entry) { + String edit = entry.getLinkHref("edit"); + assertNotNull(edit, "created entry must have an edit link"); + return edit.substring(edit.lastIndexOf("/entry/") + "/entry/".length()); + } + + @Test + public void testEntryLifecycle() throws Exception { + // ---- create ---- + EntryCollection ecol = new EntryCollection(managedUser(), ATOM_URL); + AtomEntry created = ecol.postEntry( + request("/" + HANDLE + "/entries", "application/atom+xml", null, null), + sampleEntry("First Post", "

Hello AtomPub

", false)); + + assertNotNull(created.getId()); + assertEquals("First Post", created.getTitle()); + assertFalse(created.isDraft()); + String entryId = entryIdFromEditLink(created); + TestUtils.endSession(true); + + // ---- it actually persisted ---- + WeblogEntryManager wem = WebloggerFactory.getWeblogger().getWeblogEntryManager(); + WeblogEntry persisted = wem.getWeblogEntry(entryId); + assertNotNull(persisted); + assertEquals("First Post", persisted.getTitle()); + assertEquals("

Hello AtomPub

", persisted.getText()); + assertEquals(PubStatus.PUBLISHED, persisted.getStatus()); + assertTrue(persisted.getTags().stream().anyMatch(t -> "testing".equals(t.getName()))); + TestUtils.endSession(true); + + // ---- retrieve single entry ---- + ecol = new EntryCollection(managedUser(), ATOM_URL); + AtomEntry fetched = ecol.getEntry(request("/" + HANDLE + "/entry/" + entryId, null, null, null)); + assertEquals("First Post", fetched.getTitle()); + assertEquals("

Hello AtomPub

", fetched.getContent().getValue()); + TestUtils.endSession(true); + + // ---- retrieve collection ---- + ecol = new EntryCollection(managedUser(), ATOM_URL); + AtomFeed feed = ecol.getCollection(request("/" + HANDLE + "/entries", null, null, null)); + assertTrue(feed.getEntries().stream().anyMatch(e -> "First Post".equals(e.getTitle()))); + TestUtils.endSession(true); + + // ---- update (and flip to draft) ---- + ecol = new EntryCollection(managedUser(), ATOM_URL); + ecol.putEntry(request("/" + HANDLE + "/entry/" + entryId, "application/atom+xml", null, null), + sampleEntry("First Post (edited)", "

Edited body

", true)); + TestUtils.endSession(true); + + wem = WebloggerFactory.getWeblogger().getWeblogEntryManager(); + WeblogEntry updated = wem.getWeblogEntry(entryId); + assertEquals("First Post (edited)", updated.getTitle()); + assertEquals("

Edited body

", updated.getText()); + assertEquals(PubStatus.DRAFT, updated.getStatus()); + TestUtils.endSession(true); + + // ---- delete ---- + ecol = new EntryCollection(managedUser(), ATOM_URL); + ecol.deleteEntry(request("/" + HANDLE + "/entry/" + entryId, null, null, null)); + TestUtils.endSession(true); + + wem = WebloggerFactory.getWeblogger().getWeblogEntryManager(); + assertNull(wem.getWeblogEntry(entryId)); + } + + @Test + public void testServiceDocument() throws Exception { + AtomServiceDoc service = new RollerAtomService(managedUser(), ATOM_URL).getServiceDoc(); + + assertFalse(service.getWorkspaces().isEmpty()); + AtomWorkspace workspace = service.getWorkspaces().get(0); + + // entries collection present, pointing at this weblog + AtomCollection entries = workspace.getCollections().stream() + .filter(c -> c.getHref() != null && c.getHref().endsWith("/" + HANDLE + "/entries")) + .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()); + assertTrue(entries.getCategories().stream().anyMatch(AtomCategories::isFixed)); + + // the document serializes to well-formed XML + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + new AtomWriter().writeServiceDoc(out, service); + assertTrue(out.size() > 0); + } + + @Test + public void testMediaUpload() throws Exception { + byte[] bytes = "fake-png-bytes".getBytes(StandardCharsets.UTF_8); + + // a named upload directory, the way the service document advertises them + MediaFileManager mfm = WebloggerFactory.getWeblogger().getMediaFileManager(); + Weblog weblog = WebloggerFactory.getWeblogger().getWeblogManager().getWeblogByHandle(HANDLE); + mfm.createMediaFileDirectory(weblog, "atomuploads"); + TestUtils.endSession(true); + + // ---- upload ---- + MediaCollection mcol = new MediaCollection(managedUser(), ATOM_URL); + AtomEntry mediaIn = new AtomEntry(); + AtomContent content = new AtomContent(); + content.setType("image/png"); + mediaIn.setContent(content); + mediaIn.setTitle("snapshot"); + + AtomEntry created = mcol.postMedia( + request("/" + HANDLE + "/resources/atomuploads", "image/png", "snapshot", bytes), + mediaIn); + + assertNotNull(created.getLinkHref("edit")); + assertNotNull(created.getLinkHref("edit-media")); + String fileName = created.getTitle(); + TestUtils.endSession(true); + + // ---- it persisted into the named media directory ---- + mfm = WebloggerFactory.getWeblogger().getMediaFileManager(); + weblog = WebloggerFactory.getWeblogger().getWeblogManager().getWeblogByHandle(HANDLE); + MediaFileDirectory dir = mfm.getMediaFileDirectoryByName(weblog, "atomuploads"); + assertNotNull(dir, "named upload directory should exist"); + MediaFile stored = dir.getMediaFiles().stream() + .filter(mf -> mf.getName().equals(fileName)) + .findFirst().orElse(null); + assertNotNull(stored, "uploaded media file should be persisted"); + assertEquals("image/png", stored.getContentType()); + } +} diff --git a/app/src/test/resources/atompub/app-service.rnc b/app/src/test/resources/atompub/app-service.rnc new file mode 100644 index 0000000000..c7320097ec --- /dev/null +++ b/app/src/test/resources/atompub/app-service.rnc @@ -0,0 +1,182 @@ +# RELAX NG Compact Syntax Grammar for the Atom Publishing Protocol service +# document (RFC 5023, Appendix B, the start = appService grammar). Used by +# AtomSchemaValidationTest to verify that the service documents produced by +# AtomWriter conform to RFC 5023. + +namespace app = "http://www.w3.org/2007/app" +namespace atom = "http://www.w3.org/2005/Atom" +namespace xsd = "http://www.w3.org/2001/XMLSchema" +namespace xhtml = "http://www.w3.org/1999/xhtml" +namespace local = "" + +start = appService + +# common:attrs + +atomURI = text + +appCommonAttributes = + attribute xml:base { atomURI }?, + attribute xml:lang { atomLanguageTag }?, + attribute xml:space {"default"|"preserved"}?, + undefinedAttribute* + +atomCommonAttributes = appCommonAttributes + +undefinedAttribute = attribute * - (xml:base | xml:space | xml:lang + | local:*) { text } + +atomLanguageTag = xsd:string { + pattern = "([A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*)?" +} + +atomDateConstruct = + appCommonAttributes, + xsd:dateTime + +# app:service +appService = + element app:service { + appCommonAttributes, + ( appWorkspace+ + & extensionElement* ) + } + +# app:workspace + +appWorkspace = + element app:workspace { + appCommonAttributes, + ( atomTitle + & appCollection* + & extensionSansTitleElement* ) + } + +atomTitle = element atom:title { atomTextConstruct } + +# app:collection + +appCollection = + element app:collection { + appCommonAttributes, + attribute href { atomURI }, + ( atomTitle + & appAccept* + & appCategories* + & extensionSansTitleElement* ) + } + +# app:categories + +atomCategory = + element atom:category { + atomCommonAttributes, + attribute term { text }, + attribute scheme { atomURI }?, + attribute label { text }?, + undefinedContent + } + +appInlineCategories = + element app:categories { + attribute fixed { "yes" | "no" }?, + attribute scheme { atomURI }?, + (atomCategory*, + undefinedContent) + } + +appOutOfLineCategories = + element app:categories { + attribute href { atomURI }, + undefinedContent + } + +appCategories = appInlineCategories | appOutOfLineCategories + +# app:accept + +appAccept = + element app:accept { + appCommonAttributes, + ( text? ) + } + +# Simple Extension + +simpleSansTitleExtensionElement = + element * - (app:*|atom:title) { + text + } + +simpleExtensionElement = + element * - (app:*) { + text + } + +# Structured Extension + +structuredSansTitleExtensionElement = + element * - (app:*|atom:title) { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +structuredExtensionElement = + element * - (app:*) { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +# Other Extensibility + +extensionSansTitleElement = + simpleSansTitleExtensionElement|structuredSansTitleExtensionElement + +extensionElement = simpleExtensionElement | + structuredExtensionElement + +undefinedContent = (text|anyForeignElement)* + +# Extensions + +anyElement = + element * { + (attribute * { text } + | text + | anyElement)* + } + +anyForeignElement = + element * - app:* { + (attribute * { text } + | text + | anyElement)* + } + +atomPlainTextConstruct = + atomCommonAttributes, + attribute type { "text" | "html" }?, + text + +atomXHTMLTextConstruct = + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + +atomTextConstruct = atomPlainTextConstruct | atomXHTMLTextConstruct + +anyXHTML = element xhtml:* { + (attribute * { text } + | text + | anyXHTML)* +} + +xhtmlDiv = element xhtml:div { + (attribute * { text } + | text + | anyXHTML)* +} diff --git a/app/src/test/resources/atompub/atom.rnc b/app/src/test/resources/atompub/atom.rnc new file mode 100644 index 0000000000..6bd5bfbda2 --- /dev/null +++ b/app/src/test/resources/atompub/atom.rnc @@ -0,0 +1,278 @@ +# RELAX NG Compact Syntax Grammar for the Atom Format Specification (RFC 4287, +# Appendix B). Used by AtomSchemaValidationTest to verify that the entries and +# feeds produced by AtomWriter conform to the Atom Syndication Format. The +# embedded Schematron (s:*) annotations are ignored by Jing's RELAX NG validator. + +namespace atom = "http://www.w3.org/2005/Atom" +namespace xhtml = "http://www.w3.org/1999/xhtml" +namespace s = "http://www.ascc.net/xml/schematron" +namespace local = "" + +start = atomFeed | atomEntry + +atomCommonAttributes = + attribute xml:base { atomUri }?, + attribute xml:lang { atomLanguageTag }?, + undefinedAttribute* + +atomPlainTextConstruct = + atomCommonAttributes, + attribute type { "text" | "html" }?, + text + +atomXHTMLTextConstruct = + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + +atomTextConstruct = atomPlainTextConstruct | atomXHTMLTextConstruct + +atomPersonConstruct = + atomCommonAttributes, + (element atom:name { text } + & element atom:uri { atomUri }? + & element atom:email { atomEmailAddress }? + & extensionElement*) + +atomDateConstruct = + atomCommonAttributes, + xsd:dateTime + +atomFeed = + [ + s:rule [ + context = "atom:feed" + s:assert [ + test = "atom:author or not(atom:entry[not(atom:author)])" + "An atom:feed must have an atom:author unless all of its" + ~ "atom:entry children have an atom:author." + ] + ] + ] + element atom:feed { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContributor* + & atomGenerator? + & atomIcon? + & atomId + & atomLink* + & atomLogo? + & atomRights? + & atomSubtitle? + & atomTitle + & atomUpdated + & extensionElement*), + atomEntry* + } + +atomEntry = + [ + s:rule [ + context = "atom:entry" + s:assert [ + test = "atom:link[@rel='alternate'] " + ~ "or atom:link[not(@rel)] " + ~ "or atom:content" + "An atom:entry must have at least one atom:link element " + ~ "with a rel attribute of 'alternate' " + ~ "or an atom:content." + ] + ] + s:rule [ + context = "atom:entry" + s:assert [ + test = "atom:author or " + ~ "../atom:author or atom:source/atom:author" + "An atom:entry must have an atom:author " + ~ "if its feed does not." + ] + ] + ] + element atom:entry { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContent? + & atomContributor* + & atomId + & atomLink* + & atomPublished? + & atomRights? + & atomSource? + & atomSummary? + & atomTitle + & atomUpdated + & extensionElement*) + } + +atomInlineTextContent = + element atom:content { + atomCommonAttributes, + attribute type { "text" | "html" }?, + (text)* + } + +atomInlineXHTMLContent = + element atom:content { + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + } + +atomInlineOtherContent = + element atom:content { + atomCommonAttributes, + attribute type { atomMediaType }?, + (text|anyElement)* + } + +atomOutOfLineContent = + element atom:content { + atomCommonAttributes, + attribute type { atomMediaType }?, + attribute src { atomUri }, + empty + } + +atomContent = atomInlineTextContent + | atomInlineXHTMLContent + | atomInlineOtherContent + | atomOutOfLineContent + +atomAuthor = element atom:author { atomPersonConstruct } + +atomCategory = + element atom:category { + atomCommonAttributes, + attribute term { text }, + attribute scheme { atomUri }?, + attribute label { text }?, + undefinedContent + } + +atomContributor = element atom:contributor { atomPersonConstruct } + +atomGenerator = element atom:generator { + atomCommonAttributes, + attribute uri { atomUri }?, + attribute version { text }?, + text +} + +atomIcon = element atom:icon { + atomCommonAttributes, + (atomUri) +} + +atomId = element atom:id { + atomCommonAttributes, + (atomUri) +} + +atomLogo = element atom:logo { + atomCommonAttributes, + (atomUri) +} + +atomLink = + element atom:link { + atomCommonAttributes, + attribute href { atomUri }, + attribute rel { atomNCName | atomUri }?, + attribute type { atomMediaType }?, + attribute hreflang { atomLanguageTag }?, + attribute title { text }?, + attribute length { text }?, + undefinedContent + } + +atomPublished = element atom:published { atomDateConstruct } + +atomRights = element atom:rights { atomTextConstruct } + +atomSource = + element atom:source { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContributor* + & atomGenerator? + & atomIcon? + & atomId? + & atomLink* + & atomLogo? + & atomRights? + & atomSubtitle? + & atomTitle? + & atomUpdated? + & extensionElement*) + } + +atomSubtitle = element atom:subtitle { atomTextConstruct } + +atomSummary = element atom:summary { atomTextConstruct } + +atomTitle = element atom:title { atomTextConstruct } + +atomUpdated = element atom:updated { atomDateConstruct } + +atomNCName = xsd:string { minLength = "1" pattern = "[^:]*" } + +atomMediaType = xsd:string { pattern = ".+/.+" } + +atomLanguageTag = xsd:string { + pattern = "[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*" +} + +atomUri = text + +atomEmailAddress = xsd:string { pattern = ".+@.+" } + +simpleExtensionElement = + element * - atom:* { + text + } + +structuredExtensionElement = + element * - atom:* { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +extensionElement = + simpleExtensionElement | structuredExtensionElement + +undefinedAttribute = + attribute * - (xml:base | xml:lang | local:*) { text } + +undefinedContent = (text|anyForeignElement)* + +anyElement = + element * { + (attribute * { text } + | text + | anyElement)* + } + +anyForeignElement = + element * - atom:* { + (attribute * { text } + | text + | anyElement)* + } + +anyXHTML = element xhtml:* { + (attribute * { text } + | text + | anyXHTML)* +} + +xhtmlDiv = element xhtml:div { + (attribute * { text } + | text + | anyXHTML)* +} From edb5e9ecf1badae642ed755a5f95d8e2339707e9 Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 12:12:28 -0400 Subject: [PATCH 2/4] Address PR #161 review findings - Stream media bodies to MediaCollection instead of buffering them; only Atom entries are read into memory, under the entry size limit. - AtomReader reads only direct children of atom:entry (ignores atom:source) and accepts type="xhtml" text constructs. - Media DELETE through the edit URI looks up the file without the .media-link suffix; missing media on GET, PUT and DELETE is a 404. - POST without Content-Type returns 415. - Write app:edited as a child of atom:entry (RFC 5023 10.2) and declare charset=utf-8 on the entry media type. - Factor AtomWriter document setup into one helper. - Note the removed AtomHandlerFactory extension point in CHANGES.md. --- CHANGES.md | 10 + .../atomprotocol/AtomConstants.java | 2 +- .../webservices/atomprotocol/AtomReader.java | 224 ++++++++++++++---- .../webservices/atomprotocol/AtomRequest.java | 30 ++- .../webservices/atomprotocol/AtomWriter.java | 141 +++++------ .../atomprotocol/MediaCollection.java | 37 ++- .../atomprotocol/RollerAtomServlet.java | 24 +- .../atomprotocol/AtomReaderTest.java | 29 +++ .../atomprotocol/AtomWriterTest.java | 2 + .../atomprotocol/MediaCollectionPathTest.java | 30 +++ .../atomprotocol/RollerAtomServletTest.java | 17 +- 11 files changed, 400 insertions(+), 146 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 68a5944c8b..0d85174854 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,15 @@ # Apache Roller — Changes +## 7.0.0 (in development) + +### Behaviour changes worth reading before upgrading + +- **AtomPub no longer uses ROME Propono.** Roller now has its own AtomPub + server. The `propono.properties` file and the `RollerAtomHandlerFactory` + extension point are removed, so a deployment that configured its own + `AtomHandlerFactory` (for example, for custom authentication) no longer has + that handler. AtomPub clients see no change. + ## 6.1.7 ### Improvements 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 07cf3d4609..0e747f07c2 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 @@ -32,7 +32,7 @@ private AtomConstants() { public static final String APP_NS = "http://www.w3.org/2007/app"; /** Media type for an Atom entry. */ - public static final String ENTRY_MEDIA_TYPE = "application/atom+xml;type=entry"; + public static final String ENTRY_MEDIA_TYPE = "application/atom+xml;type=entry;charset=utf-8"; /** Media type for an Atom feed/collection. */ public static final String FEED_MEDIA_TYPE = "application/atom+xml;type=feed;charset=utf-8"; 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 99f833179b..4940d78214 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 @@ -42,6 +42,8 @@ */ public class AtomReader { + private static final String XHTML_NS = "http://www.w3.org/1999/xhtml"; + private final XMLInputFactory factory; public AtomReader() { @@ -67,57 +69,19 @@ static Date parseDate(String text) { } /** - * Parse an atom:entry from the given stream. The author is intentionally not - * read; the server sets the entry's creator from the authenticated user. + * Parse an atom:entry from the given stream. Only direct children of the + * entry are read, so metadata inside nested elements such as atom:source + * is ignored. The author is intentionally not read; the server sets the + * entry's creator from the authenticated user. */ public AtomEntry parseEntry(InputStream in) throws AtomException { XMLStreamReader r = null; try { r = factory.createXMLStreamReader(in, "UTF-8"); AtomEntry entry = new AtomEntry(); - while (r.hasNext()) { - int event = r.next(); - if (event == XMLStreamConstants.DTD) { - throw new AtomException("DOCTYPE is not allowed in an Atom entry", - HttpServletResponse.SC_BAD_REQUEST, null); - } - if (event != XMLStreamConstants.START_ELEMENT) { - continue; - } - String ns = r.getNamespaceURI(); - String name = r.getLocalName(); - if (ATOM_NS.equals(ns)) { - switch (name) { - case "id": - entry.setId(r.getElementText()); - break; - case "title": - entry.setTitle(r.getElementText()); - break; - case "summary": - entry.setSummary(readContent(r)); - break; - case "content": - if (entry.getContent() == null) { - entry.setContent(readContent(r)); - } - break; - case "published": - entry.setPublished(parseDate(r.getElementText())); - break; - case "updated": - entry.setUpdated(parseDate(r.getElementText())); - break; - case "category": - entry.getCategories().add(readCategory(r)); - break; - default: - break; - } - } else if (APP_NS.equals(ns) && "draft".equals(name)) { - String value = r.getElementText(); - entry.setDraft(value != null && value.trim().equalsIgnoreCase("yes")); - } + moveToRootElement(r); + while (r.nextTag() == XMLStreamConstants.START_ELEMENT) { + readEntryChild(r, entry); } return entry; } catch (XMLStreamException ex) { @@ -134,17 +98,183 @@ public AtomEntry parseEntry(InputStream in) throws AtomException { } } + private static void moveToRootElement(XMLStreamReader r) + throws XMLStreamException, AtomException { + while (r.hasNext()) { + int event = r.next(); + if (event == XMLStreamConstants.DTD) { + throw new AtomException("DOCTYPE is not allowed in an Atom entry", + HttpServletResponse.SC_BAD_REQUEST, null); + } + if (event == XMLStreamConstants.START_ELEMENT) { + return; + } + } + throw new XMLStreamException("No root element"); + } + + /** + * Reads one child of atom:entry. On return the reader is on the child's + * END_ELEMENT. + */ + private void readEntryChild(XMLStreamReader r, AtomEntry entry) throws XMLStreamException { + String ns = r.getNamespaceURI(); + String name = r.getLocalName(); + if (ATOM_NS.equals(ns)) { + switch (name) { + case "id": + entry.setId(r.getElementText()); + return; + case "title": + entry.setTitle(readContent(r).getValue()); + return; + case "summary": + entry.setSummary(readContent(r)); + return; + case "content": + AtomContent content = readContent(r); + if (entry.getContent() == null) { + entry.setContent(content); + } + return; + case "published": + entry.setPublished(parseDate(r.getElementText())); + return; + case "updated": + entry.setUpdated(parseDate(r.getElementText())); + return; + case "category": + entry.getCategories().add(readCategory(r)); + skipElement(r); + return; + default: + break; + } + } else if (APP_NS.equals(ns) && "control".equals(name)) { + while (r.nextTag() == XMLStreamConstants.START_ELEMENT) { + if (APP_NS.equals(r.getNamespaceURI()) && "draft".equals(r.getLocalName())) { + String value = r.getElementText(); + entry.setDraft(value != null && value.trim().equalsIgnoreCase("yes")); + } else { + skipElement(r); + } + } + return; + } + skipElement(r); + } + + /** Moves the reader from a START_ELEMENT to its matching END_ELEMENT. */ + private static void skipElement(XMLStreamReader r) throws XMLStreamException { + int depth = 1; + while (depth > 0) { + int event = r.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + depth++; + } else if (event == XMLStreamConstants.END_ELEMENT) { + depth--; + } + } + } + + /** + * Reads an Atom text construct or atom:content. For type="xhtml" the value + * is the markup inside the wrapping xhtml:div (RFC 4287 3.1.1.3). + */ private AtomContent readContent(XMLStreamReader r) throws XMLStreamException { AtomContent content = new AtomContent(); - content.setType(r.getAttributeValue(null, "type")); + String type = r.getAttributeValue(null, "type"); + content.setType(type); String src = r.getAttributeValue(null, "src"); content.setSrc(src); - if (src == null) { + if (src != null) { + skipElement(r); + } else if ("xhtml".equals(type)) { + content.setValue(readXhtml(r)); + } else { content.setValue(r.getElementText()); } return content; } + /** + * Serializes the children of the current element as markup without + * namespace declarations. A single wrapping xhtml:div is left out. Empty + * elements are written as {@code
}, never {@code

}. + */ + private String readXhtml(XMLStreamReader r) throws XMLStreamException { + StringBuilder out = new StringBuilder(); + int depth = 1; + boolean skipDiv = false; + boolean openTag = false; + while (depth > 0) { + int event = r.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + depth++; + if (openTag) { + out.append('>'); + openTag = false; + } + if (depth == 2 && XHTML_NS.equals(r.getNamespaceURI()) + && "div".equals(r.getLocalName()) && out.toString().isBlank()) { + skipDiv = true; + continue; + } + out.append('<').append(r.getLocalName()); + for (int i = 0; i < r.getAttributeCount(); i++) { + out.append(' ').append(r.getAttributeLocalName(i)).append("=\"") + .append(escape(r.getAttributeValue(i), true)).append('"'); + } + openTag = true; + } else if (event == XMLStreamConstants.END_ELEMENT) { + depth--; + if (depth == 0 || (depth == 1 && skipDiv)) { + continue; + } + if (openTag) { + out.append("/>"); + openTag = false; + } else { + out.append("'); + } + } else if (event == XMLStreamConstants.CHARACTERS + || event == XMLStreamConstants.CDATA + || event == XMLStreamConstants.SPACE) { + if (depth > 1 || !skipDiv) { + if (openTag) { + out.append('>'); + openTag = false; + } + out.append(escape(r.getText(), false)); + } + } + } + return out.toString().trim(); + } + + private static String escape(String text, boolean attribute) { + StringBuilder sb = new StringBuilder(text.length()); + for (char c : text.toCharArray()) { + switch (c) { + case '&': + sb.append("&"); + break; + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '"': + sb.append(attribute ? """ : "\""); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } + private AtomCategory readCategory(XMLStreamReader r) { AtomCategory cat = new AtomCategory(); cat.setTerm(r.getAttributeValue(null, "term")); 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 cd6e620038..a0651fedcc 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 @@ -18,15 +18,17 @@ package org.apache.roller.weblogger.webservices.atomprotocol; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.io.InputStream; import javax.servlet.http.HttpServletRequest; /** * Lightweight wrapper around an {@link HttpServletRequest} for AtomPub handlers. - * The dispatcher servlet reads any request body once into a byte array and - * passes it here so handlers can read it (and so {@code getPathInfo()} never - * returns null for the service-document URI). + * The dispatcher servlet reads an Atom entry body once into a byte array and + * passes it here so handlers can read it. Media bodies are not buffered: they + * are streamed from the request (see {@link #streaming(HttpServletRequest)}). + * {@code getPathInfo()} never returns null for the service-document URI. */ public class AtomRequest { @@ -34,10 +36,21 @@ public class AtomRequest { private final HttpServletRequest request; private final byte[] body; + private final boolean streaming; public AtomRequest(HttpServletRequest request, byte[] body) { + this(request, (body != null) ? body : EMPTY, false); + } + + private AtomRequest(HttpServletRequest request, byte[] body, boolean streaming) { this.request = request; - this.body = (body != null) ? body : EMPTY; + this.body = body; + this.streaming = streaming; + } + + /** A request whose body is read directly from the servlet request, once. */ + public static AtomRequest streaming(HttpServletRequest request) { + return new AtomRequest(request, null, true); } /** Path info relative to the AtomPub servlet, never null ("" for the service doc). */ @@ -54,9 +67,12 @@ public String getContentType() { return request.getContentType(); } - /** A fresh stream over the buffered request body. */ - public InputStream getInputStream() { - return new ByteArrayInputStream(body); + /** + * A fresh stream over the buffered request body, or the servlet request's + * stream for a streaming request. + */ + public InputStream getInputStream() throws IOException { + return streaming ? request.getInputStream() : new ByteArrayInputStream(body); } public HttpServletRequest getRequest() { 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 1366a883a0..2c4c7eab1e 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 @@ -51,33 +51,12 @@ static String formatDate(Date date) { } public void writeEntry(OutputStream out, AtomEntry entry) throws AtomException { - try { - XMLStreamWriter w = factory.createXMLStreamWriter(out, "UTF-8"); - w.writeStartDocument("UTF-8", "1.0"); - w.setDefaultNamespace(ATOM_NS); - w.setPrefix("app", APP_NS); - w.writeStartElement(ATOM_NS, "entry"); - w.writeDefaultNamespace(ATOM_NS); - w.writeNamespace("app", APP_NS); - writeEntryBody(w, entry); - w.writeEndElement(); - w.writeEndDocument(); - w.flush(); - w.close(); - } catch (XMLStreamException ex) { - throw new AtomException("Error serializing Atom entry", ex); - } + writeDocument(out, ATOM_NS, "entry", "app", APP_NS, "Error serializing Atom entry", + w -> writeEntryBody(w, entry)); } public void writeFeed(OutputStream out, AtomFeed feed) throws AtomException { - try { - XMLStreamWriter w = factory.createXMLStreamWriter(out, "UTF-8"); - w.writeStartDocument("UTF-8", "1.0"); - w.setDefaultNamespace(ATOM_NS); - w.setPrefix("app", APP_NS); - w.writeStartElement(ATOM_NS, "feed"); - w.writeDefaultNamespace(ATOM_NS); - w.writeNamespace("app", APP_NS); + writeDocument(out, ATOM_NS, "feed", "app", APP_NS, "Error serializing Atom feed", w -> { writeAtomText(w, "id", feed.getId()); writeAtomText(w, "title", feed.getTitle()); writeAtomText(w, "updated", formatDate(feed.getUpdated())); @@ -89,62 +68,87 @@ public void writeFeed(OutputStream out, AtomFeed feed) throws AtomException { writeEntryBody(w, entry); w.writeEndElement(); } - w.writeEndElement(); - w.writeEndDocument(); - w.flush(); - w.close(); - } catch (XMLStreamException ex) { - throw new AtomException("Error serializing Atom feed", ex); - } + }); } public void writeServiceDoc(OutputStream out, AtomServiceDoc service) throws AtomException { - try { - XMLStreamWriter w = factory.createXMLStreamWriter(out, "UTF-8"); - w.writeStartDocument("UTF-8", "1.0"); - w.setDefaultNamespace(APP_NS); - w.setPrefix("atom", ATOM_NS); - w.writeStartElement(APP_NS, "service"); - w.writeDefaultNamespace(APP_NS); - w.writeNamespace("atom", ATOM_NS); + writeDocument(out, APP_NS, "service", "atom", ATOM_NS, "Error serializing service document", w -> { for (AtomWorkspace workspace : service.getWorkspaces()) { w.writeStartElement(APP_NS, "workspace"); writeAtomText(w, "title", workspace.getTitle()); for (AtomCollection collection : workspace.getCollections()) { - w.writeStartElement(APP_NS, "collection"); - if (collection.getHref() != null) { - w.writeAttribute("href", collection.getHref()); - } - writeAtomText(w, "title", collection.getTitle()); - for (String accept : collection.getAccepts()) { - w.writeStartElement(APP_NS, "accept"); - w.writeCharacters(accept); - w.writeEndElement(); - } - for (AtomCategories cats : collection.getCategories()) { - w.writeStartElement(APP_NS, "categories"); - w.writeAttribute("fixed", cats.isFixed() ? "yes" : "no"); - if (cats.getScheme() != null) { - w.writeAttribute("scheme", cats.getScheme()); - } - for (AtomCategory cat : cats.getCategories()) { - writeCategory(w, cat); - } - w.writeEndElement(); - } - w.writeEndElement(); + writeCollection(w, collection); } w.writeEndElement(); } + }); + } + + /** Writes the body of a document element. */ + @FunctionalInterface + private interface ElementBody { + void write(XMLStreamWriter w) throws XMLStreamException; + } + + /** + * Writes a UTF-8 document whose root element is in the default namespace + * {@code rootNs}, with {@code otherNs} bound to {@code prefix}. + */ + private void writeDocument(OutputStream out, String rootNs, String root, + String prefix, String otherNs, String errorMessage, ElementBody body) + throws AtomException { + XMLStreamWriter w = null; + try { + w = factory.createXMLStreamWriter(out, "UTF-8"); + w.writeStartDocument("UTF-8", "1.0"); + w.setDefaultNamespace(rootNs); + w.setPrefix(prefix, otherNs); + w.writeStartElement(rootNs, root); + w.writeDefaultNamespace(rootNs); + w.writeNamespace(prefix, otherNs); + body.write(w); w.writeEndElement(); w.writeEndDocument(); w.flush(); - w.close(); } catch (XMLStreamException ex) { - throw new AtomException("Error serializing service document", ex); + throw new AtomException(errorMessage, ex); + } finally { + if (w != null) { + try { + w.close(); + } catch (XMLStreamException ignored) { + // nothing useful to do on close failure + } + } } } + private void writeCollection(XMLStreamWriter w, AtomCollection collection) + throws XMLStreamException { + w.writeStartElement(APP_NS, "collection"); + if (collection.getHref() != null) { + w.writeAttribute("href", collection.getHref()); + } + writeAtomText(w, "title", collection.getTitle()); + for (String accept : collection.getAccepts()) { + w.writeStartElement(APP_NS, "accept"); + w.writeCharacters(accept); + w.writeEndElement(); + } + for (AtomCategories cats : collection.getCategories()) { + w.writeStartElement(APP_NS, "categories"); + w.writeAttribute("fixed", cats.isFixed() ? "yes" : "no"); + if (cats.getScheme() != null) { + w.writeAttribute("scheme", cats.getScheme()); + } + for (AtomCategory cat : cats.getCategories()) { + writeCategory(w, cat); + } + w.writeEndElement(); + } + w.writeEndElement(); + } + private void writeEntryBody(XMLStreamWriter w, AtomEntry entry) throws XMLStreamException { writeAtomText(w, "id", entry.getId()); writeAtomText(w, "title", entry.getTitle()); @@ -168,16 +172,17 @@ private void writeEntryBody(XMLStreamWriter w, AtomEntry entry) throws XMLStream for (AtomLink link : entry.getLinks()) { writeLink(w, link); } - // APP control extension (RFC 5023) - w.writeStartElement(APP_NS, "control"); - w.writeStartElement(APP_NS, "draft"); - w.writeCharacters(entry.isDraft() ? "yes" : "no"); - 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) { w.writeStartElement(APP_NS, "edited"); w.writeCharacters(formatDate(entry.getEdited())); w.writeEndElement(); } + w.writeStartElement(APP_NS, "control"); + w.writeStartElement(APP_NS, "draft"); + w.writeCharacters(entry.isDraft() ? "yes" : "no"); + w.writeEndElement(); w.writeEndElement(); } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java index 632e9fe311..147e1d782b 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java @@ -229,7 +229,12 @@ public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException // Parse pathinfo to determine file path String filePath = filePathFromPathInfo(pathInfo); MediaFile mf = fmgr.getMediaFileByOriginalPath(website, filePath); + if (mf == null) { + throw new AtomNotFoundException("Cannot find media file: " + filePath); + } return createMediaResource(mf, response); + } catch (AtomException ae) { + throw ae; } catch (Exception e) { throw new AtomException( "Unexpected error during file upload", e); @@ -373,8 +378,6 @@ public void putMedia(AtomRequest areq) throws AtomException { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); String contentType = areq.getContentType(); try { - InputStream is = areq.getInputStream(); - // authenticated client posted a weblog entry File tempFile = null; String handle = pathInfo[0]; @@ -387,18 +390,22 @@ public void putMedia(AtomRequest areq) throws AtomException { if (pathInfo.length > 1) { // Save to temp file try { - tempFile = File.createTempFile(UUID.randomUUID().toString(), "tmp"); - FileOutputStream fos = new FileOutputStream(tempFile); - Utilities.copyInputToOutput(is, fos); - fos.close(); - - FileInputStream fis = new FileInputStream(tempFile); - // Parse pathinfo to determine file path String path = filePathFromPathInfo(pathInfo); // Attempt to load file, to ensure it exists MediaFile mf = fmgr.getMediaFileByPath(website, path); + if (mf == null) { + throw new AtomNotFoundException("Cannot find media file: " + path); + } + + tempFile = File.createTempFile(UUID.randomUUID().toString(), "tmp"); + try (InputStream is = areq.getInputStream(); + FileOutputStream fos = new FileOutputStream(tempFile)) { + Utilities.copyInputToOutput(is, fos); + } + + FileInputStream fis = new FileInputStream(tempFile); String replacementName = pathInfo[pathInfo.length - 1]; String declaredType = MediaTypePolicy.normalizeType(contentType); RollerMessages errors = new RollerMessages(); @@ -421,6 +428,8 @@ public void putMedia(AtomRequest areq) throws AtomException { log.debug("Exiting"); return; + } catch (AtomException ae) { + throw ae; } catch (FileIOException fie) { throw new AtomException( "File upload disabled, over-quota or other error", fie); @@ -451,14 +460,20 @@ public void deleteEntry(AtomRequest areq) throws AtomException { } if (RollerAtomHandler.canEdit(user, website) && pathInfo.length > 1) { try { + // The edit URI ends in .media-link; the edit-media URI does not String path = filePathFromPathInfo(pathInfo); - String fileName = path.substring(0, path.length() - ".media-link".length()); + String fileName = StringUtils.removeEnd(path, ".media-link"); MediaFileManager fmgr = roller.getMediaFileManager(); - MediaFile mf = fmgr.getMediaFileByPath(website, path); + MediaFile mf = fmgr.getMediaFileByPath(website, fileName); + if (mf == null) { + throw new AtomNotFoundException("Cannot find media file: " + fileName); + } fmgr.removeMediaFile(website, mf); log.debug("Deleted media entry: " + fileName); return; + } catch (AtomException ae) { + throw ae; } catch (Exception e) { String msg = "ERROR deleting media entry"; log.error(msg, e); 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 867460aad4..edde176044 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 @@ -84,12 +84,18 @@ protected void service(HttpServletRequest request, HttpServletResponse response) return; } - byte[] body = null; + if ("POST".equals(method) && request.getContentType() == null) { + sendText(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, + "No content-type specified in request"); + return; + } + + AtomRequest areq; AtomEntry entry = null; if (carriesEntry(request)) { int maxEntryBytes = maxEntryBytes(); // Read one byte past the limit, so an oversized body can be detected. - body = request.getInputStream().readNBytes(maxEntryBytes + 1); + byte[] body = request.getInputStream().readNBytes(maxEntryBytes + 1); if (body.length > maxEntryBytes) { sendText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Entry is too large"); @@ -102,10 +108,12 @@ protected void service(HttpServletRequest request, HttpServletResponse response) sendText(response, HttpServletResponse.SC_BAD_REQUEST, "Invalid Atom entry"); return; } - } else if ("POST".equals(method) || "PUT".equals(method)) { - body = readBody(request); + areq = new AtomRequest(request, body); + } else { + // Media bodies are streamed to a temporary file by MediaCollection, + // where the upload size and quota are checked. + areq = AtomRequest.streaming(request); } - AtomRequest areq = new AtomRequest(request, body); try { switch (method) { @@ -270,10 +278,4 @@ private void writeCreated(HttpServletResponse response, AtomEntry entry) throw new AtomException("Error writing created entry", ioe); } } - - private byte[] readBody(HttpServletRequest request) throws IOException { - try (InputStream in = request.getInputStream()) { - return in.readAllBytes(); - } - } } 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 bc101c7fff..e5f4ce6a04 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 @@ -149,4 +149,33 @@ public void testMalformedEntryIsBadRequest() { AtomException ex = assertThrows(AtomException.class, () -> parse("</entry>")); assertEquals(HttpServletResponse.SC_BAD_REQUEST, ex.getStatus()); } + + @Test + public void testSourceMetadataIsIgnored() throws Exception { + String xml = + "<entry xmlns='http://www.w3.org/2005/Atom'>" + + "<source><title>Other Blog2020-01-01T00:00:00Z" + + "Mine" + + ""; + AtomEntry entry = parse(xml); + assertEquals("Mine", entry.getTitle()); + assertNull(entry.getUpdated()); + } + + @Test + public void testXhtmlContentAndTitle() throws Exception { + String xml = + "" + + "<div xmlns='http://www.w3.org/1999/xhtml'>A <em>title</em></div>" + + "" + + " " + + "One & twothree" + + "" + + "" + + ""; + AtomEntry entry = parse(xml); + assertEquals("A title", entry.getTitle()); + assertEquals("xhtml", entry.getContent().getType()); + assertEquals("

One & two
three

", entry.getContent().getValue()); + } } 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 25caf2d149..36870dd8ec 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 @@ -126,6 +126,8 @@ public void testWriteEntryStructure() throws Exception { // draft "no" plus app:edited present assertEquals("no", text(doc, APP_NS, "draft")); assertEquals(AtomWriter.formatDate(UPDATED), text(doc, APP_NS, "edited")); + // RFC 5023 10.2: app:edited is a direct child of atom:entry + assertEquals(doc.getDocumentElement(), element(doc, APP_NS, "edited").getParentNode()); } @Test diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java index 6dc823d7ce..7f6df91e1c 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java @@ -25,6 +25,7 @@ import org.apache.roller.weblogger.business.Weblogger; import org.apache.roller.weblogger.business.WebloggerFactory; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +import org.apache.roller.weblogger.pojos.MediaFile; import org.apache.roller.weblogger.pojos.MediaFileDirectory; import org.apache.roller.weblogger.pojos.User; import org.apache.roller.weblogger.pojos.Weblog; @@ -139,6 +140,35 @@ void mediaWithoutSlugOrTitleIsNamedByDate() throws Exception { verify(files, never()).createMediaFile(any(), any(), any()); } + @Test + void deleteThroughTheEditUriRemovesTheMediaFile() throws Exception { + MediaFile file = mock(MediaFile.class); + when(files.getMediaFileByPath(weblog, "default/a.png")).thenReturn(file); + + collection.deleteEntry(request("/blog/resource/default/a.png.media-link")); + + verify(files).removeMediaFile(weblog, file); + } + + @Test + void deleteOfAnUnknownFileIsNotFound() throws Exception { + assertThrows(AtomNotFoundException.class, + () -> collection.deleteEntry(request("/blog/resource/default/missing.png.media-link"))); + verify(files, never()).removeMediaFile(any(), any()); + } + + @Test + void getOfAnUnknownMediaResourceIsNotFound() { + assertThrows(AtomNotFoundException.class, + () -> collection.getMediaResource(request("/blog/resource/default/missing.png"))); + } + + @Test + void putOfAnUnknownMediaResourceIsNotFound() { + assertThrows(AtomNotFoundException.class, + () -> collection.putMedia(request("/blog/resource/default/missing.png"))); + } + private void refuseUploads() throws Exception { when(content.canSave(any(), anyString(), anyString(), anyLong(), any())) .thenAnswer(call -> { 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 c135ef1e7e..d80823b2b6 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 @@ -47,6 +47,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -144,10 +145,24 @@ void mediaUploadsBypassTheEntryLimit() throws Exception { private void mediaUploadBypassesTheEntryLimit(String method, String path) throws Exception { config.when(() -> WebloggerRuntimeConfig.getProperty(RollerAtomServlet.MAX_ENTRY_SIZE_PROPERTY)) .thenReturn("1"); + HttpServletRequest request = request(method, path, "image/png", "not xml"); - servlet.service(request(method, path, "image/png", "not xml"), response); + servlet.service(request, response); assertArrayEquals("not xml".getBytes(StandardCharsets.UTF_8), servlet.body); + // The servlet does not buffer media; the handler reads the request stream + verify(request, times(1)).getInputStream(); + } + + @Test + void postWithoutContentTypeIsUnsupportedMediaType() throws Exception { + HttpServletRequest request = request("POST", "/blog/resources", null, "data"); + + servlet.service(request, response); + + verify(response).setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); + verify(request, never()).getInputStream(); + assertNull(servlet.body); } @Test From e5419f3d4c5b290cab1b902b67cc53bbcf3ba8f0 Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 13:16:25 -0400 Subject: [PATCH 3/4] Address CodeQL alerts and review findings on PR #161 - Send only the status code for 5xx AtomPub errors and unexpected exceptions; log the details server-side. - Set Location/Content-Location only when the edit URI is a valid URI under this server's AtomPub URL (no CR/LF, no foreign host). - Require view permission for media-link entries and return 404 for an unknown weblog, matching the media collection. - Remove the unused commons-httpclient 3.1 dependency. --- app/pom.xml | 15 ---- .../atomprotocol/MediaCollection.java | 6 ++ .../atomprotocol/RollerAtomHandler.java | 5 ++ .../atomprotocol/RollerAtomServlet.java | 46 +++++++++--- .../atomprotocol/MediaCollectionPathTest.java | 15 ++++ .../atomprotocol/RollerAtomServletTest.java | 70 ++++++++++++++++++- 6 files changed, 130 insertions(+), 27 deletions(-) diff --git a/app/pom.xml b/app/pom.xml index e0b79ab157..77e43b509a 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -529,21 +529,6 @@ limitations under the License. - - - commons-httpclient - commons-httpclient - 3.1 - compile - - - commons-logging - commons-logging - - - - net.oauth.core oauth-provider diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java index 147e1d782b..7626ff7016 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java @@ -197,6 +197,12 @@ public AtomEntry getEntry(AtomRequest areq) throws AtomException { filePath = filePath.substring(0, filePath.length() - ".media-link".length()); String handle = pathInfo[0]; Weblog website = roller.getWeblogManager().getWeblogByHandle(handle); + if (website == null) { + throw new AtomNotFoundException("Cannot find weblog: " + handle); + } + if (!RollerAtomHandler.canView(user, website)) { + throw new AtomNotAuthorizedException("Not authorized to access website"); + } MediaFileManager fileMgr = roller.getMediaFileManager(); MediaFile mf = fileMgr.getMediaFileByPath(website, filePath); 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 b8b578df9e..2844bd1ef5 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 @@ -139,6 +139,11 @@ public RollerAtomHandler(HttpServletRequest request, HttpServletResponse respons atomURL = WebloggerFactory.getWeblogger().getUrlStrategy().getAtomProtocolURL(true); } + /** The absolute AtomPub URL that this server's links start with. */ + String getAtomURL() { + return atomURL; + } + /** * Return weblogHandle of authenticated user or null if there is none. */ 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 edde176044..4c765ec982 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 @@ -21,6 +21,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; @@ -131,18 +133,42 @@ protected void service(HttpServletRequest request, HttpServletResponse response) response.setStatus(HttpServletResponse.SC_OK); } } catch (AtomException ae) { - log.debug("Returning error to client: " + ae.getMessage(), ae); if (!response.isCommitted()) { - response.sendError(ae.getStatus(), ae.getMessage()); + if (ae.getStatus() >= HttpServletResponse.SC_INTERNAL_SERVER_ERROR) { + // Server errors can carry internal details; keep them in the log + log.error("Error handling AtomPub request", ae); + response.sendError(ae.getStatus()); + } else { + // Client errors carry a message written by Roller for the client + log.debug("Returning error to client: " + ae.getMessage(), ae); + response.sendError(ae.getStatus(), ae.getMessage()); + } } } catch (Exception e) { log.error("Unexpected error handling AtomPub request", e); if (!response.isCommitted()) { - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } } + /** + * Returns the edit URI as a header value, or null when it is not a valid + * URI under this server's AtomPub URL. The URI is built in part from + * client input (the Slug header and the request path), so this keeps the + * Location header from pointing elsewhere or carrying CR/LF. + */ + static String safeLocation(String href, String atomURL) { + if (href == null || atomURL == null || !href.startsWith(atomURL + "/")) { + return null; + } + try { + return new URI(href).toASCIIString(); + } catch (URISyntaxException e) { + return null; + } + } + /** Creates the handler that authenticates the request. */ protected RollerAtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) { return new RollerAtomHandler(req, res); @@ -245,7 +271,7 @@ private void doPost(RollerAtomHandler handler, AtomRequest areq, AtomEntry entry mediaEntry.setTitle(areq.getHeader("Slug")); created = handler.postMedia(areq, mediaEntry); } - writeCreated(response, created); + writeCreated(handler, response, created); } private void doPut(RollerAtomHandler handler, AtomRequest areq, AtomEntry entry, @@ -262,12 +288,12 @@ private void doPut(RollerAtomHandler handler, AtomRequest areq, AtomEntry entry, } } - private void writeCreated(HttpServletResponse response, AtomEntry entry) - throws AtomException { - String editHref = entry.getLinkHref("edit"); - if (editHref != null) { - response.setHeader("Location", editHref); - response.setHeader("Content-Location", editHref); + private void writeCreated(RollerAtomHandler handler, HttpServletResponse response, + AtomEntry entry) throws AtomException { + String location = safeLocation(entry.getLinkHref("edit"), handler.getAtomURL()); + if (location != null) { + response.setHeader("Location", location); + response.setHeader("Content-Location", location); } response.setStatus(HttpServletResponse.SC_CREATED); response.setContentType(AtomConstants.ENTRY_MEDIA_TYPE); diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java index 7f6df91e1c..b8d956263c 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java @@ -157,6 +157,21 @@ void deleteOfAnUnknownFileIsNotFound() throws Exception { verify(files, never()).removeMediaFile(any(), any()); } + @Test + void mediaLinkEntryOfAnUnknownWeblogIsNotFound() { + assertThrows(AtomNotFoundException.class, + () -> collection.getEntry(request("/nosuchblog/resource/default/a.png.media-link"))); + } + + @Test + void mediaLinkEntryNeedsPermissionOnTheWeblog() throws Exception { + when(weblog.hasUserPermission(any(), eq(WeblogPermission.POST))).thenReturn(false); + + assertThrows(AtomNotAuthorizedException.class, + () -> collection.getEntry(request("/blog/resource/default/a.png.media-link"))); + verify(files, never()).getMediaFileByPath(any(), anyString()); + } + @Test void getOfAnUnknownMediaResourceIsNotFound() { assertThrows(AtomNotFoundException.class, 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 d80823b2b6..c603b60414 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 @@ -42,6 +42,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -283,6 +286,54 @@ private void unauthenticatedEntryIsRefusedWithoutReadingTheBody(String method, S assertNull(servlet.body); } + @Test + void serverErrorsDoNotExposeTheExceptionMessage() throws Exception { + servlet.failure = new IllegalStateException("secret internal detail"); + + servlet.service(request("GET", "/blog/entries", null, ""), response); + + verify(response).sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + verify(response, never()).sendError(anyInt(), anyString()); + } + + @Test + void clientErrorsKeepTheirMessage() throws Exception { + servlet.service(request("GET", "/blog/nowhere", null, ""), response); + + verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "Cannot find specified resource"); + } + + @Test + void locationMustBeAValidUriUnderTheAtomUrl() { + String atom = "https://blog.example/roller-services/app"; + assertEquals(atom + "/blog/entry/1", RollerAtomServlet.safeLocation(atom + "/blog/entry/1", atom)); + assertEquals(atom + "/blog/resource/caf%C3%A9.png.media-link", + RollerAtomServlet.safeLocation(atom + "/blog/resource/café.png.media-link", atom)); + assertNull(RollerAtomServlet.safeLocation("https://evil.example/x", atom)); + assertNull(RollerAtomServlet.safeLocation(atom + ".evil.example/x", atom)); + assertNull(RollerAtomServlet.safeLocation(atom + "/blog/a\r\nSet-Cookie: x=y", atom)); + assertNull(RollerAtomServlet.safeLocation(atom + "/blog/entry/1", null)); + assertNull(RollerAtomServlet.safeLocation(null, atom)); + } + + @Test + void createdEntryLocationIsSetOnlyWhenSafe() throws Exception { + servlet.atomURL = "https://blog.example/roller-services/app"; + servlet.editHref = servlet.atomURL + "/blog/entry/1"; + + servlet.service(request("POST", "/blog/entries", "application/atom+xml", ENTRY), response); + + verify(response).setStatus(HttpServletResponse.SC_CREATED); + verify(response).setHeader("Location", servlet.editHref); + + HttpServletResponse unsafe = response(); + servlet.editHref = "https://evil.example/x"; + servlet.service(request("POST", "/blog/entries", "application/atom+xml", ENTRY), unsafe); + + verify(unsafe).setStatus(HttpServletResponse.SC_CREATED); + verify(unsafe, never()).setHeader(eq("Location"), anyString()); + } + private static HttpServletResponse response() throws IOException { HttpServletResponse response = mock(HttpServletResponse.class); when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); @@ -355,6 +406,9 @@ public void setReadListener(ReadListener listener) { private static final class RecordingServlet extends RollerAtomServlet { private static final long serialVersionUID = 1L; String userName = "alice"; + String atomURL; + String editHref; + RuntimeException failure; RollerAtomHandler handler; AtomEntry postedEntry; byte[] body; @@ -364,17 +418,29 @@ protected RollerAtomHandler createHandler(HttpServletRequest req, HttpServletRes handler = mock(RollerAtomHandler.class); try { when(handler.getAuthenticatedUsername()).thenReturn(userName); + when(handler.getAtomURL()).thenReturn(atomURL); when(handler.isCollectionURI(any())).thenAnswer(call -> call.getArgument(0).getPathInfo().matches("/[^/]+/(entries|resources).*")); when(handler.isEntryURI(any())).thenAnswer(call -> RollerAtomHandler.isEntryPath(call.getArgument(0).getPathInfo())); when(handler.isMediaEditURI(any())).thenAnswer(call -> call.getArgument(0).getPathInfo().matches("/[^/]+/resource/.*")); - when(handler.getCollection(any())).thenReturn(new AtomFeed()); + if (failure != null) { + when(handler.getCollection(any())).thenThrow(failure); + } else { + when(handler.getCollection(any())).thenReturn(new AtomFeed()); + } when(handler.postEntry(any(), any())).thenAnswer(call -> { record(call.getArgument(0)); postedEntry = call.getArgument(1); - return new AtomEntry(); + AtomEntry created = new AtomEntry(); + if (editHref != null) { + AtomLink edit = new AtomLink(); + edit.setRel("edit"); + edit.setHref(editHref); + created.getLinks().add(edit); + } + return created; }); when(handler.postMedia(any(), any())).thenAnswer(call -> { record(call.getArgument(0)); From 35fe89870f923d863d0bcec285e5b18be251651b Mon Sep 17 00:00:00 2001 From: "David M. Johnson" Date: Sat, 10 Oct 2026 13:37:30 -0400 Subject: [PATCH 4/4] Keep Jing from downgrading Xerces; credit the RFC schemas - Exclude xercesImpl and xml-apis from the test-scoped Jing dependency. Jing declares them with runtime scope, which replaced the WAR's Xerces 2.11.0 and xml-apis 1.4.01 with 2.9.1 and 1.0.b2. - Add source and copyright headers to the RFC 4287 and RFC 5023 RELAX NG schemas used by AtomSchemaValidationTest. --- app/pom.xml | 13 +++++++++++++ app/src/test/resources/atompub/app-service.rnc | 10 ++++++++++ app/src/test/resources/atompub/atom.rnc | 10 ++++++++++ 3 files changed, 33 insertions(+) diff --git a/app/pom.xml b/app/pom.xml index 77e43b509a..e2ddac3eda 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -592,6 +592,19 @@ limitations under the License. jing 20091111 test + + + + xerces + xercesImpl + + + xml-apis + xml-apis + + diff --git a/app/src/test/resources/atompub/app-service.rnc b/app/src/test/resources/atompub/app-service.rnc index c7320097ec..05d45008f2 100644 --- a/app/src/test/resources/atompub/app-service.rnc +++ b/app/src/test/resources/atompub/app-service.rnc @@ -1,3 +1,13 @@ +# Copied from RFC 5023, "The Atom Publishing Protocol", Appendix B +# (https://www.rfc-editor.org/rfc/rfc5023#appendix-B). Used only by tests; +# not included in the Roller web application. +# +# Copyright (C) The IETF Trust (2007). +# +# This document is subject to the rights, licenses and restrictions contained +# in BCP 78, and except as set forth therein, the authors retain all their +# rights. +# # RELAX NG Compact Syntax Grammar for the Atom Publishing Protocol service # document (RFC 5023, Appendix B, the start = appService grammar). Used by # AtomSchemaValidationTest to verify that the service documents produced by diff --git a/app/src/test/resources/atompub/atom.rnc b/app/src/test/resources/atompub/atom.rnc index 6bd5bfbda2..d510245981 100644 --- a/app/src/test/resources/atompub/atom.rnc +++ b/app/src/test/resources/atompub/atom.rnc @@ -1,3 +1,13 @@ +# Copied from RFC 4287, "The Atom Syndication Format", Appendix B +# (https://www.rfc-editor.org/rfc/rfc4287#appendix-B). Used only by tests; +# not included in the Roller web application. +# +# Copyright (C) The Internet Society (2005). +# +# This document is subject to the rights, licenses and restrictions contained +# in BCP 78, and except as set forth therein, the authors retain all their +# rights. +# # RELAX NG Compact Syntax Grammar for the Atom Format Specification (RFC 4287, # Appendix B). Used by AtomSchemaValidationTest to verify that the entries and # feeds produced by AtomWriter conform to the Atom Syndication Format. The